diff --git a/Dockerfile b/Dockerfile index 72447ae0b2efa114b3073f0838a817ccd769912e..9b1805ea8974f8523cc36bcc23eb03d211fd4361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN apt-get -y update && \ libpq-dev \ libssl-dev \ netcat-openbsd \ + postgresql-client \ yarnpkg && \ eatmydata pip install uwsgi django-compressor diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index f4599d4d0e13ac02d01fda147331a3997d0f8dd0..feb7d996b7cca83c040ef7add8a5355e82335e82 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -1,8 +1,11 @@ from datetime import datetime, time +from typing import Callable, Dict, List, Sequence, Tuple from django import forms from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError +from django.db.models import QuerySet +from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, Select2Widget @@ -370,3 +373,144 @@ class DashboardWidgetOrderForm(ExtensibleForm): DashboardWidgetOrderFormSet = forms.formset_factory( form=DashboardWidgetOrderForm, max_num=0, extra=0 ) + + +class ActionForm(forms.Form): + """Generic form for executing actions on multiple items of a queryset. + + This should be used together with a ``Table`` from django-tables2 + which includes a ``SelectColumn``. + + The queryset can be defined in two different ways: + You can use ``get_queryset`` or provide ``queryset`` as keyword argument + at the initialization of this form class. + If both are declared, it will use the keyword argument. + + Any actions can be defined using the ``actions`` class attribute + or overriding the method ``get_actions``. + The actions use the same syntax like the Django Admin actions with one important difference: + Instead of the related model admin, + these actions will get the related ``ActionForm`` as first argument. + Here you can see an example for such an action: + + .. code-block:: python + + from django.utils.translation import gettext as _ + + def example_action(form, request, queryset): + # Do something with this queryset + + example_action.short_description = _("Example action") + + If you can include the ``ActionForm`` like any other form in your views, + but you must add the request as first argument. + When the form is valid, you should run ``execute``: + + .. code-block:: python + + from aleksis.core.forms import ActionForm + + def your_view(request, ...): + # Something + action_form = ActionForm(request, request.POST or None, ...) + if request.method == "POST" and form.is_valid(): + form.execute() + + # Something + """ + + layout = Layout("action") + actions = [] + + def get_actions(self) -> Sequence[Callable]: + """Get all defined actions.""" + return self.actions + + def _get_actions_dict(self) -> Dict[str, Callable]: + """Get all defined actions as dictionary.""" + return {value.__name__: value for value in self.get_actions()} + + def _get_action_choices(self) -> List[Tuple[str, str]]: + """Get all defined actions as Django choices.""" + return [ + (value.__name__, getattr(value, "short_description", value.__name__)) + for value in self.get_actions() + ] + + def get_queryset(self) -> QuerySet: + """Get the related queryset.""" + raise NotImplementedError("Queryset necessary.") + + action = forms.ChoiceField(choices=[]) + selected_objects = forms.ModelMultipleChoiceField(queryset=None) + + def __init__(self, request: HttpRequest, *args, queryset: QuerySet = None, **kwargs): + self.request = request + self.queryset = queryset if isinstance(queryset, QuerySet) else self.get_queryset() + super().__init__(*args, **kwargs) + self.fields["selected_objects"].queryset = self.queryset + self.fields["action"].choices = self._get_action_choices() + + def execute(self) -> bool: + """Execute the selected action on all selected objects. + + :return: If the form is not valid, it will return ``False``. + """ + if self.is_valid(): + data = self.cleaned_data["selected_objects"] + action = self._get_actions_dict()[self.cleaned_data["action"]] + action(None, self.request, data) + return True + return False + + +class ListActionForm(ActionForm): + """Generic form for executing actions on multiple items of a list of dictionaries. + + Sometimes you want to implement actions for data from different sources + than querysets or even querysets from multiple models. For these cases, + you can use this form. + + To provide an unique identification of each item, the dictionaries **must** + include the attribute ``pk``. This attribute has to be unique for the whole list. + If you don't mind this aspect, this will cause unexpected behavior. + + Any actions can be defined as described in ``ActionForm``, but, of course, + the last argument won't be a queryset but a list of dictionaries. + + For further information on usage, you can take a look at ``ActionForm``. + """ + + selected_objects = forms.MultipleChoiceField(choices=[]) + + def get_queryset(self): + # Return None in order not to raise an unwanted exception + return None + + def _get_dict(self) -> Dict[str, dict]: + """Get the items sorted by pk attribute.""" + return {item["pk"]: item for item in self.items} + + def _get_choices(self) -> List[Tuple[str, str]]: + """Get the items as Django choices.""" + return [(item["pk"], item["pk"]) for item in self.items] + + def _get_real_items(self, items: Sequence[dict]) -> List[dict]: + """Get the real dictionaries from a list of pks.""" + items_dict = self._get_dict() + real_items = [] + for item in items: + if item not in items_dict: + raise ValidationError(_("No valid selection.")) + real_items.append(items_dict[item]) + return real_items + + def clean_selected_objects(self) -> List[dict]: + data = self.cleaned_data["selected_objects"] + items = self._get_real_items(data) + return items + + def __init__(self, request: HttpRequest, items, *args, **kwargs): + self.items = items + super().__init__(request, *args, **kwargs) + self.fields["selected_objects"].choices = self._get_choices() diff --git a/aleksis/core/static/js/multi_select.js b/aleksis/core/static/js/multi_select.js new file mode 100644 index 0000000000000000000000000000000000000000..cddf911b5f50be2217075d2c0f34191adab6f72e --- /dev/null +++ b/aleksis/core/static/js/multi_select.js @@ -0,0 +1,48 @@ +$(document).ready(function () { + $(".select--header-box").change(function () { + /* + If the top checkbox is checked, all sub checkboxes should be checked, + if it gets unchecked, all other ones should get unchecked. + */ + if ($(this).is(":checked")) { + $(this).closest("table").find('input[name="selected_objects"]').prop({ + indeterminate: false, + checked: true, + }); + } else { + $(this).closest("table").find('input[name="selected_objects"]').prop({ + indeterminate: false, + checked: false, + }); + } + }); + + $('input[name="selected_objects"]').change(function () { + /* + If a table checkbox changes, check the state of the other ones. + If all boxes are checked the box in the header should be checked, + if all boxes are unchecked the header box should be unchecked. If + only some boxes are checked the top one should be inderteminate. + */ + let checked = $(this).is(":checked"); + let indeterminate = false; + let table = $(this).closest("table"); + table.find('input[name="selected_objects"]').each(function () { + if ($(this).is(":checked") !== checked) { + /* Set the header box to indeterminate if the boxes are not the same */ + table.find(".select--header-box").prop({ + indeterminate: true, + }) + indeterminate = true; + return false; + } + }); + if (!(indeterminate)) { + /* All boxes are the same, set the header box to the same value */ + table.find(".select--header-box").prop({ + indeterminate: false, + checked: checked, + }); + } + }); +}); diff --git a/aleksis/core/util/tables.py b/aleksis/core/util/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..8bb9fd5c0b4212a1361ebd12b6acd5087731ca58 --- /dev/null +++ b/aleksis/core/util/tables.py @@ -0,0 +1,46 @@ +from django.utils.safestring import mark_safe + +from django_tables2 import CheckBoxColumn +from django_tables2.utils import A, AttributeDict, computed_values + + +class MaterializeCheckboxColumn(CheckBoxColumn): + """Checkbox column with Materialize support.""" + + empty_values = () + + @property + def header(self): + """Render the header cell.""" + default = {"type": "checkbox"} + general = self.attrs.get("input") + specific = self.attrs.get("th__input") + attrs = AttributeDict(default, **(specific or general or {})) + return mark_safe("<label><input %s/><span></span></label>" % attrs.as_html()) # noqa + + def render(self, value, bound_column, record): + """Render a data cell.""" + default = {"type": "checkbox", "name": bound_column.name, "value": value} + if self.is_checked(value, record): + default.update({"checked": "checked"}) + + general = self.attrs.get("input") + specific = self.attrs.get("td__input") + + attrs = dict(default, **(specific or general or {})) + attrs = computed_values(attrs, kwargs={"record": record, "value": value}) + return mark_safe( # noqa + "<label><input %s/><span></span</label>" % AttributeDict(attrs).as_html() + ) + + +class SelectColumn(MaterializeCheckboxColumn): + """Column with a check box prepared for `ActionForm` forms.""" + + def __init__(self, *args, **kwargs): + kwargs["attrs"] = { + "td__input": {"name": "selected_objects"}, + "th__input": {"class": "select--header-box"}, + } + kwargs.setdefault("accessor", A("pk")) + super().__init__(*args, **kwargs) diff --git a/docker-startup.sh b/docker-startup.sh index f0fd18ee63353f6867ea455e6190ac57a0695ee1..19ac40558e38421062be63bc178056d37a5e22b1 100755 --- a/docker-startup.sh +++ b/docker-startup.sh @@ -1,9 +1,7 @@ #!/bin/bash -HTTP_PORT=${HTTP_PORT:8000} - -export ALEKSIS_database__host=${ALEKSIS_database__host:-127.0.0.1} -export ALEKSIS_database__port=${ALEKSIS_database__port:-5432} +HTTP_PORT=${HTTP_PORT:-8000} +RUN_MODE=uwsgi if [[ -z $ALEKSIS_secret_key ]]; then if [[ ! -e /var/lib/aleksis/secret_key ]]; then @@ -13,13 +11,34 @@ if [[ -z $ALEKSIS_secret_key ]]; then ALEKSIS_secret_key=$(</var/lib/aleksis/secret_key) fi -while ! nc -z $ALEKSIS_database__host $ALEKSIS_database__port; do - sleep 0.1 +echo -n "Waiting for database." +while ! aleksis-admin dbshell -- -c "SELECT 1" >/dev/null 2>&1; do + sleep 0.5 + echo -n . done +echo -aleksis-admin migrate -aleksis-admin createinitialrevisions aleksis-admin compilescss aleksis-admin collectstatic --no-input --clear -exec aleksis-admin runuwsgi -- --http-socket=:$HTTP_PORT +case "$RUN_MODE" in + uwsgi) + aleksis-admin migrate + aleksis-admin createinitialrevisions + aleksis-admin compilescss + aleksis-admin collectstatic --no-input --clear + exec aleksis-admin runuwsgi -- --http-socket=:$HTTP_PORT + ;; + celery-worker) + aleksis-admin migrate + aleksis-admin createinitialrevisions + exec celery -A aleksis.core worker + ;; + celery-beat) + aleksis-admin migrate + exec celery -A aleksis.core beat + ;; + *) + exec "$@" + ;; +esac