diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py index 7abb6517ddfdab34f8c70a01cdd8e0e5a225c287..7f22c671539df6a150a98e843815291caae628f8 100644 --- a/aleksis/apps/kort/forms.py +++ b/aleksis/apps/kort/forms.py @@ -1,46 +1,89 @@ from django import forms +from django.db.models import Q from django.utils.translation import gettext as _ from django_ace import AceWidget -from django_select2.forms import ModelSelect2Widget +from django_select2.forms import ModelSelect2MultipleWidget from material import Fieldset, Layout, Row -from aleksis.apps.kort.models import Card, CardLayout, CardLayoutMediaFile, CardPrinter +from aleksis.apps.kort.models import CardLayout, CardLayoutMediaFile, CardPrinter +from aleksis.core.models import Group, Person -class CardForm(forms.ModelForm): +class CardIssueForm(forms.Form): + layout = Layout( + Fieldset(_("Select person(s) or group(s)"), "persons", "groups"), + Fieldset(_("Select validity"), "valid_until"), + Fieldset(_("Select layout"), "card_layout"), + Fieldset(_("Select printer (optional)"), "printer"), + ) printer = forms.ModelChoiceField( queryset=None, label=_("Card Printer"), help_text=_("Select a printer to directly print the newly issued card."), required=False, ) + persons = forms.ModelMultipleChoiceField( + queryset=None, + label=_("Persons"), + required=False, + widget=ModelSelect2MultipleWidget( + search_fields=[ + "first_name__icontains", + "last_name__icontains", + "short_name__icontains", + ], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + ) + groups = forms.ModelMultipleChoiceField( + queryset=None, + label=_("Groups"), + required=False, + widget=ModelSelect2MultipleWidget( + search_fields=[ + "name__icontains", + "short_name__icontains", + ], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + ) + card_layout = forms.ModelChoiceField(queryset=None, label=_("Card layout"), required=True) + valid_until = forms.DateField( + label=_("Valid until"), + required=True, + ) - class Meta: - model = Card - fields = ["person", "valid_until", "layout"] - - widgets = { - "person": ModelSelect2Widget( - search_fields=[ - "first_name__icontains", - "last_name__icontains", - "short_name__icontains", - ], - attrs={"data-minimum-input-length": 0, "class": "browser-default"}, - ), - } + def clean(self): + """Clean and validate person data.""" + cleaned_data = super().clean() + + # Ensure that there is at least one person selected + if not cleaned_data.get("persons") and not cleaned_data.get("groups"): + raise forms.ValidationError(_("You must select at least one person or group.")) + + cleaned_data["all_persons"] = Person.objects.filter( + Q(pk__in=cleaned_data.get("persons", [])) + | Q(member_of__in=cleaned_data.get("groups", [])) + ) + + if not cleaned_data["all_persons"].exists(): + raise forms.ValidationError(_("The selected groups don't have any members.")) + + return cleaned_data def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["layout"].required = True + # Assume that users would select the layout if there is only one layout available layouts = CardLayout.objects.all() + self.fields["card_layout"].queryset = layouts if layouts.count() == 1: - self.fields["layout"].initial = layouts.first() + self.fields["card_layout"].initial = layouts.first() - printers = CardPrinter.objects.all() - self.fields["printer"].queryset = printers + self.fields["printer"].queryset = CardPrinter.objects.all() + self.fields["persons"].queryset = Person.objects.all() + self.fields["groups"].queryset = Group.objects.all() class CardPrinterForm(forms.ModelForm): @@ -84,16 +127,32 @@ class CardLayoutMediaFileForm(forms.ModelForm): class CardLayoutForm(forms.ModelForm): - layout = Layout(Row("name"), Row("width", "height"), Row("template"), "css") + layout = Layout( + Row("name"), Row("required_fields"), Row("width", "height"), Row("template"), "css" + ) template = forms.CharField(widget=AceWidget(mode="django")) css = forms.CharField(widget=AceWidget(mode="css")) + required_fields = forms.MultipleChoiceField( + label=_("Required data fields"), required=True, choices=Person.syncable_fields_choices() + ) + class Meta: model = CardLayout - fields = ["name", "template", "css", "width", "height"] + fields = ["name", "template", "css", "width", "height", "required_fields"] CardLayoutMediaFileFormSet = forms.inlineformset_factory( CardLayout, CardLayoutMediaFile, form=CardLayoutMediaFileForm ) + + +class CardIssueFinishForm(forms.Form): + layout = Layout() + selected_objects = forms.ModelMultipleChoiceField(queryset=None, required=True) + + def __init__(self, *args, **kwargs): + queryset = kwargs.pop("queryset") + super().__init__(*args, **kwargs) + self.fields["selected_objects"].queryset = queryset diff --git a/aleksis/apps/kort/migrations/0014_auto_20220803_0025.py b/aleksis/apps/kort/migrations/0014_auto_20220803_0025.py new file mode 100644 index 0000000000000000000000000000000000000000..7935f8719d22e5321b0fe0356a4485e01d1fd55c --- /dev/null +++ b/aleksis/apps/kort/migrations/0014_auto_20220803_0025.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-08-02 22:25 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0013_auto_20220530_1939'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cardlayout', + options={'verbose_name': 'Card Layout', 'verbose_name_plural': 'Card Layouts'}, + ), + migrations.AddField( + model_name='cardlayout', + name='required_fields', + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), default=['first_name'], size=None, verbose_name='Required data fields'), + preserve_default=False, + ), + ] diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py index 83ea2c9fcd3875700de19c5828dc5762248eba66..2e8677e7a0173d8815063775348c781c7acb91c8 100644 --- a/aleksis/apps/kort/models.py +++ b/aleksis/apps/kort/models.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any, Optional, Union from django.conf import settings +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import FileExtensionValidator from django.db import models @@ -216,12 +217,12 @@ class CardLayout(ExtensibleModel): css = models.TextField(verbose_name=_("Custom CSS"), blank=True) width = models.PositiveIntegerField(verbose_name=_("Width"), help_text=_("in mm")) height = models.PositiveIntegerField(verbose_name=_("Height"), help_text=_("in mm")) + required_fields = ArrayField(models.TextField(), verbose_name=_("Required data fields")) def get_template(self) -> Template: template = self.BASE_TEMPLATE.format( width=self.width, height=self.height, css=self.css, template=self.template ) - print(template) return Template(template) def render(self, card: "Card"): diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py index 7a1f0e7bc1a8dfa0b6ec3f697cc7458231cb1d5b..c607ee34123a4648f132f698a73ffda84bdbe317 100644 --- a/aleksis/apps/kort/tables.py +++ b/aleksis/apps/kort/tables.py @@ -1,8 +1,9 @@ +from django.db.models.fields.files import ImageFieldFile from django.template.loader import render_to_string +from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext as _ from django_tables2 import ( - A, BooleanColumn, Column, DateTimeColumn, @@ -10,8 +11,11 @@ from django_tables2 import ( RelatedLinkColumn, Table, ) +from django_tables2.utils import A, AttributeDict, computed_values from aleksis.apps.kort.forms import PrinterSelectForm +from aleksis.core.models import Person +from aleksis.core.util.tables import SelectColumn class CardTable(Table): @@ -82,3 +86,66 @@ class CardLayoutTable(Table): def render_actions(self, value, record): return render_to_string("kort/card_layout/actions.html", dict(pk=value, card_layout=record)) + + +class IssueCardPersonsTable(Table): + """Table to list persons with all needed data for issueing cards.""" + + selected = SelectColumn() + status = Column(accessor=A("pk"), verbose_name=_("Status")) + + def get_missing_fields(self, person: Person): + """Return a list of missing data fields for the given person.""" + required_fields = self.card_layout.required_fields + missing_fields = [] + for field in required_fields: + if not getattr(person, field, None): + missing_fields.append(field) + return missing_fields + + def render_selected(self, value: int, record: Person) -> SafeString: + """Render the selected checkbox and mark valid rows as selected.""" + attrs = {"type": "checkbox", "name": "selected_objects", "value": value} + if not self.get_missing_fields(record): + attrs.update({"checked": "checked"}) + + attrs = computed_values(attrs, kwargs={"record": record, "value": value}) + return mark_safe( # noqa + "<label><input %s/><span></span</label>" % AttributeDict(attrs).as_html() + ) + + def render_status(self, value: int, record: Person) -> str: + """Render the status of the person data.""" + missing_fields = self.get_missing_fields(record) + return render_to_string( + "kort/person_status.html", + {"missing_fields": missing_fields, "missing_fields_count": len(missing_fields)}, + self.request, + ) + + def render_photo(self, value: ImageFieldFile, record: Person) -> str: + """Render the photo of the person as circle.""" + return render_to_string( + "kort/picture.html", + { + "picture": record.photo, + "class": "materialize-circle table-circle", + "img_class": "materialize-circle", + }, + self.request, + ) + + def render_avatar(self, value: ImageFieldFile, record: Person) -> str: + """Render the avatar of the person as circle.""" + return render_to_string( + "kort/picture.html", + { + "picture": record.avatar, + "class": "materialize-circle table-circle", + "img_class": "materialize-circle", + }, + self.request, + ) + + class Meta: + sequence = ["selected", "status", "..."] diff --git a/aleksis/apps/kort/templates/kort/card/create.html b/aleksis/apps/kort/templates/kort/card/create.html deleted file mode 100644 index 086400b8641fa9bf9c1d6118c8a161f266456254..0000000000000000000000000000000000000000 --- a/aleksis/apps/kort/templates/kort/card/create.html +++ /dev/null @@ -1,24 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load material_form i18n any_js %} - -{% block extra_head %} - {{ form.media.css }} - {% include_css "select2-materialize" %} -{% endblock %} - -{% block browser_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %} - - -{% block content %} - <form method="post" enctype="multipart/form-data"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - {% include_js "select2-materialize" %} - {{ form.media.js }} -{% endblock %} diff --git a/aleksis/apps/kort/templates/kort/card/issue.html b/aleksis/apps/kort/templates/kort/card/issue.html new file mode 100644 index 0000000000000000000000000000000000000000..1a8875ed5be3d39c3fc904b6db54cfdb2e5b44a0 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/issue.html @@ -0,0 +1,90 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load material_form i18n any_js django_tables2 static %} + +{% block extra_head %} + {{ form.media.css }} + {% include_css "select2-materialize" %} +{% endblock %} + +{% block browser_title %}{% blocktrans %}Issue card(s){% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Issue card(s){% endblocktrans %}{% endblock %} + + +{% block content %} + {% include_js "select2-materialize" %} + + <form method="post" enctype="multipart/form-data"> + {% if wizard.steps.index == 0 %} + <figure class="alert primary"> + <i class="material-icons iconify left" data-icon="mdi:information-outline"></i> + {% blocktrans %} + Please select the persons and/or the groups to whom you want to issue new cards. + After clicking on 'Next', you will be able to check whether the data of the persons + are complete and include everything needed for the cards. + {% endblocktrans %} + </figure> + {% else %} + <figure class="alert primary"> + <i class="material-icons iconify left" data-icon="mdi:information-outline"></i> + {% blocktrans %} + In the following table you can see all selected persons and the related data needed for the cards. + Please select the persons to whom you want to issue new cards. + {% endblocktrans %} + </figure> + {% endif %} + + <div class="margin-bottom"> + {% csrf_token %} + {{ wizard.management_form }} + {% if wizard.form.forms %} + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + {% form form=form %}{% endform %} + {{ form.media.js }} + {% endfor %} + {% else %} + {% form form=wizard.form %}{% endform %} + {{ form.media.js }} + {% endif %} + {% if persons_table %} + {{ wizard.form.selected_objects.errors }} + {% render_table persons_table %} + {% endif %} + </div> + + <div class="row"> + <div class="col s12"> + {% if wizard.steps.prev %} + <button name="wizard_goto_step" class="btn primary waves-effect waves-light margin-bottom" + type="submit" + value="{{ wizard.steps.prev }}"> + <i class="material-icons left">arrow_back</i> + {% trans "Previous step" %} + </button> + {% endif %} + + {% if wizard.steps.count|add:"-1" == wizard.steps.index %} + <button type="submit" class="btn green waves-effect waves-light margin-bottom"> + {% trans "Issue cards for selected persons" %}<i class="material-icons right">send</i> + </button> + {% else %} + <button class="btn primary-color waves-effect waves-light margin-bottom" type="submit" + value="{{ wizard.steps.prev }}"> + <i class="material-icons right">arrow_forward</i> + {% trans "Next step" %} + </button> + {% endif %} + + <a href="{% url "cards" %}" + class="btn-flat waves-effect waves-red red-text margin-bottom"> + <i class="material-icons left">clear</i> {% trans "Cancel" %} + </a> + </div> + </div> + </form> + + <script src="{% static "js/multi_select.js" %}" type="text/javascript"></script> +{% endblock %} diff --git a/aleksis/apps/kort/templates/kort/card/list.html b/aleksis/apps/kort/templates/kort/card/list.html index 5f96123eb76ab63a52dabfc133850b649ae07e18..69da48d34adf62de715f26d562a32272c61027e0 100644 --- a/aleksis/apps/kort/templates/kort/card/list.html +++ b/aleksis/apps/kort/templates/kort/card/list.html @@ -14,7 +14,7 @@ {% if can_create_person %} <a class="btn green waves-effect waves-light" href="{% url 'create_card' %}"> <i class="material-icons left iconify" data-icon="mdi:plus"></i> - {% trans "Issue new card" %} + {% trans "Issue new card(s)" %} </a> {% endif %} diff --git a/aleksis/apps/kort/templates/kort/person_status.html b/aleksis/apps/kort/templates/kort/person_status.html new file mode 100644 index 0000000000000000000000000000000000000000..c87caf4f13c37f1363c05e31aa5664f845bb3319 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/person_status.html @@ -0,0 +1,12 @@ +{% load i18n %} +{% if missing_fields_count %} + <span class="red-text"> + <i class="material-icons iconify left" data-icon="mdi:alert-octagon-outline"></i> + {% blocktrans with count=missing_fields_count %}{{ count }} missing data fields{% endblocktrans %} + </span> +{% else %} + <span class="green-text"> + <i class="material-icons iconify left" data-icon="mdi:check-circle-outline"></i> + {% blocktrans %}Data complete{% endblocktrans %} + </span> +{% endif %} diff --git a/aleksis/apps/kort/templates/kort/picture.html b/aleksis/apps/kort/templates/kort/picture.html new file mode 100644 index 0000000000000000000000000000000000000000..a921461f56e0b285039b3a9a48e25a66ef8cbe02 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/picture.html @@ -0,0 +1,11 @@ +{% load i18n %} +{% if picture %} + <div class="{% firstof class "clip-circle" %}"> + <img class="{% firstof img_class "hundred-percent" %}" src="{{ picture.url }}" alt="{% trans "Person picture" %}"/> + </div> +{% else %} + {# There is a user without a person #} + <div class="{% firstof class "clip-circle" %} no-image"> + <i class="material-icons">person</i> + </div> +{% endif %} diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py index 1d31213bc4cf687d5512c9c96cf796f30b51cdd8..7c5001b4c2c6f4cddc1beb33c1b1701a8ebde172 100644 --- a/aleksis/apps/kort/urls.py +++ b/aleksis/apps/kort/urls.py @@ -5,7 +5,7 @@ from . import api, views urlpatterns = [ path("test", views.TestPDFView.as_view(), name="test_pdf"), path("cards/", views.CardListView.as_view(), name="cards"), - path("cards/create/", views.CardCreateView.as_view(), name="create_card"), + path("cards/create/", views.CardIssueView.as_view(), name="create_card"), path("cards/<int:pk>/", views.CardDetailView.as_view(), name="card"), path( "cards/<int:pk>/generate_pdf/", diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py index d6181110b7df5aff5e9a1fc8ef820e747087cac8..a168782a66ce19da38ee25d88d878824bd7a2308 100644 --- a/aleksis/apps/kort/views.py +++ b/aleksis/apps/kort/views.py @@ -1,7 +1,8 @@ import json from django.contrib import messages -from django.db.models import Count, Q +from django.db.models import Count, Q, QuerySet +from django.forms import Form from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy @@ -9,20 +10,28 @@ from django.utils.translation import gettext as _ from django.views import View from django.views.generic.detail import DetailView, SingleObjectMixin -from django_tables2 import SingleTableView +from django_tables2 import RequestConfig, SingleTableView, table_factory +from formtools.wizard.views import CookieWizardView from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin from aleksis.apps.kort.forms import ( - CardForm, + CardIssueFinishForm, + CardIssueForm, CardLayoutForm, CardLayoutMediaFileFormSet, CardPrinterForm, PrinterSelectForm, ) from aleksis.apps.kort.models import Card, CardLayout, CardPrinter, PrintStatus -from aleksis.apps.kort.tables import CardLayoutTable, CardPrinterTable, CardTable +from aleksis.apps.kort.tables import ( + CardLayoutTable, + CardPrinterTable, + CardTable, + IssueCardPersonsTable, +) from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView +from aleksis.core.models import Person from aleksis.core.util.celery_progress import render_progress_page from aleksis.core.views import RenderPDFView @@ -60,37 +69,93 @@ class CardListView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, S return Card.objects.order_by("-pk") -class CardCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView): - """View used to create a card.""" +class CardIssueView(PermissionRequiredMixin, RevisionMixin, CookieWizardView): + """View used to issue one or more cards.""" permission_required = "core.create_card_rule" context_object_name = "application" - template_name = "kort/card/create.html" - form_class = CardForm - success_message = _("The card has been created successfully.") + template_name = "kort/card/issue.html" + form_list = [CardIssueForm, CardIssueFinishForm] + success_message = _("The cards have been created successfully.") success_url = reverse_lazy("cards") - def form_valid(self, form: CardForm) -> HttpResponse: - response = super().form_valid(form) - if form.cleaned_data.get("printer"): - printer = form.cleaned_data["printer"] - try: - job = self.object.print_card(printer) - messages.success( - self.request, - _( - "The print job #{} for the card {} on " - "the printer {} has been created successfully." - ).format(job.pk, self.object.person, printer.name), - ) - except ValueError as e: - messages.error( - self.request, - _( - "The print job couldn't be started because of the following error: {}" - ).format(e), - ) - return response + def _get_data(self) -> dict[str, any]: + return self.get_cleaned_data_for_step("0") + + def _get_persons(self) -> QuerySet: + """Get all persons selected in the first step.""" + return self._get_data()["all_persons"] + + def get_form_initial(self, step: str) -> dict[str, any]: + if step == "1": + return {"persons": self._get_persons()} + return super().get_form_initial(step) + + def get_form_kwargs(self, step: str = None) -> dict[str, any]: + kwargs = super().get_form_kwargs(step) + if step == "1": + kwargs["queryset"] = self._get_persons() + return kwargs + + def get_form_prefix(self, step: str = None, form: Form = None): + prefix = super().get_form_prefix(step, form) + if step == "1": + return None + return prefix + + def get_context_data(self, form: Form, **kwargs) -> dict[str, any]: + context = super().get_context_data(form, **kwargs) + if self.steps.current == "1": + table_obj = table_factory( + Person, + IssueCardPersonsTable, + fields=self._get_data()["card_layout"].required_fields, + ) + table_obj.card_layout = self._get_data()["card_layout"] + persons_table = table_obj(self._get_persons()) + context["persons_table"] = RequestConfig(self.request, paginate=False).configure( + persons_table + ) + + return context + + def done(self, form_list: list[Form], **kwargs) -> HttpResponse: + first_data = form_list[0].cleaned_data + second_data = form_list[1].cleaned_data + + # Firstly, create all the cards + cards = [] + for person in second_data["selected_objects"]: + card = Card( + person=person, + layout=first_data["card_layout"], + valid_until=first_data["valid_until"], + ) + cards.append(card) + Card.objects.bulk_create(cards) + messages.success(self.request, self.success_message) + + # Secondly, print the cards (if activated) + if first_data.get("printer"): + printer = first_data["printer"] + for card in cards: + try: + job = card.print_card(printer) + messages.success( + self.request, + _( + "The print job #{} for the card {} on " + "the printer {} has been created successfully." + ).format(job.pk, card.person, printer.name), + ) + except ValueError as e: + messages.error( + self.request, + _( + "The print job couldn't be started because of the following error: {}" + ).format(e), + ) + return redirect(self.success_url) class CardDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): diff --git a/pyproject.toml b/pyproject.toml index a70a6284ea6e98e1397af5db11bff02be20194c2..b24acf37e60e3e20b241d1ddc515411c01752759 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ python = "^3.9" aleksis-core = "^2.8" python-barcode = "^0.14.0" django-ace = "^1.0.12" +django-formtools = "^2.3" [tool.poetry.dev-dependencies] aleksis-builddeps = "*"