Skip to content
Snippets Groups Projects
Verified Commit 6ac6446b authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Extend issue view to allow mass issueing and implement data check

Close #5, #7
parent f571cd4a
No related branches found
No related tags found
No related merge requests found
Pipeline #82454 failed
from django import forms from django import forms
from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_ace import AceWidget from django_ace import AceWidget
from django_select2.forms import ModelSelect2Widget from django_select2.forms import ModelSelect2MultipleWidget
from material import Fieldset, Layout, Row 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( printer = forms.ModelChoiceField(
queryset=None, queryset=None,
label=_("Card Printer"), label=_("Card Printer"),
help_text=_("Select a printer to directly print the newly issued card."), help_text=_("Select a printer to directly print the newly issued card."),
required=False, 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: def clean(self):
model = Card """Clean and validate person data."""
fields = ["person", "valid_until", "layout"] cleaned_data = super().clean()
widgets = { # Ensure that there is at least one person selected
"person": ModelSelect2Widget( if not cleaned_data.get("persons") and not cleaned_data.get("groups"):
search_fields=[ raise forms.ValidationError(_("You must select at least one person or group."))
"first_name__icontains",
"last_name__icontains", cleaned_data["all_persons"] = Person.objects.filter(
"short_name__icontains", Q(pk__in=cleaned_data.get("persons", []))
], | Q(member_of__in=cleaned_data.get("groups", []))
attrs={"data-minimum-input-length": 0, "class": "browser-default"}, )
),
} 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): def __init__(self, *args, **kwargs):
super().__init__(*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() layouts = CardLayout.objects.all()
self.fields["card_layout"].queryset = layouts
if layouts.count() == 1: 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 = CardPrinter.objects.all()
self.fields["printer"].queryset = printers self.fields["persons"].queryset = Person.objects.all()
self.fields["groups"].queryset = Group.objects.all()
class CardPrinterForm(forms.ModelForm): class CardPrinterForm(forms.ModelForm):
...@@ -84,16 +127,32 @@ class CardLayoutMediaFileForm(forms.ModelForm): ...@@ -84,16 +127,32 @@ class CardLayoutMediaFileForm(forms.ModelForm):
class CardLayoutForm(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")) template = forms.CharField(widget=AceWidget(mode="django"))
css = forms.CharField(widget=AceWidget(mode="css")) css = forms.CharField(widget=AceWidget(mode="css"))
required_fields = forms.MultipleChoiceField(
label=_("Required data fields"), required=True, choices=Person.syncable_fields_choices()
)
class Meta: class Meta:
model = CardLayout model = CardLayout
fields = ["name", "template", "css", "width", "height"] fields = ["name", "template", "css", "width", "height", "required_fields"]
CardLayoutMediaFileFormSet = forms.inlineformset_factory( CardLayoutMediaFileFormSet = forms.inlineformset_factory(
CardLayout, CardLayoutMediaFile, form=CardLayoutMediaFileForm 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
# 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,
),
]
...@@ -3,6 +3,7 @@ from datetime import timedelta ...@@ -3,6 +3,7 @@ from datetime import timedelta
from typing import Any, Optional, Union from typing import Any, Optional, Union
from django.conf import settings from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
...@@ -216,12 +217,12 @@ class CardLayout(ExtensibleModel): ...@@ -216,12 +217,12 @@ class CardLayout(ExtensibleModel):
css = models.TextField(verbose_name=_("Custom CSS"), blank=True) css = models.TextField(verbose_name=_("Custom CSS"), blank=True)
width = models.PositiveIntegerField(verbose_name=_("Width"), help_text=_("in mm")) width = models.PositiveIntegerField(verbose_name=_("Width"), help_text=_("in mm"))
height = models.PositiveIntegerField(verbose_name=_("Height"), 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: def get_template(self) -> Template:
template = self.BASE_TEMPLATE.format( template = self.BASE_TEMPLATE.format(
width=self.width, height=self.height, css=self.css, template=self.template width=self.width, height=self.height, css=self.css, template=self.template
) )
print(template)
return Template(template) return Template(template)
def render(self, card: "Card"): def render(self, card: "Card"):
......
from django.db.models.fields.files import ImageFieldFile
from django.template.loader import render_to_string 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.utils.translation import gettext as _
from django_tables2 import ( from django_tables2 import (
A,
BooleanColumn, BooleanColumn,
Column, Column,
DateTimeColumn, DateTimeColumn,
...@@ -10,8 +11,11 @@ from django_tables2 import ( ...@@ -10,8 +11,11 @@ from django_tables2 import (
RelatedLinkColumn, RelatedLinkColumn,
Table, Table,
) )
from django_tables2.utils import A, AttributeDict, computed_values
from aleksis.apps.kort.forms import PrinterSelectForm from aleksis.apps.kort.forms import PrinterSelectForm
from aleksis.core.models import Person
from aleksis.core.util.tables import SelectColumn
class CardTable(Table): class CardTable(Table):
...@@ -82,3 +86,66 @@ class CardLayoutTable(Table): ...@@ -82,3 +86,66 @@ class CardLayoutTable(Table):
def render_actions(self, value, record): def render_actions(self, value, record):
return render_to_string("kort/card_layout/actions.html", dict(pk=value, card_layout=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", "..."]
{# -*- 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 %}
{# -*- 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 %}
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
{% if can_create_person %} {% if can_create_person %}
<a class="btn green waves-effect waves-light" href="{% url 'create_card' %}"> <a class="btn green waves-effect waves-light" href="{% url 'create_card' %}">
<i class="material-icons left iconify" data-icon="mdi:plus"></i> <i class="material-icons left iconify" data-icon="mdi:plus"></i>
{% trans "Issue new card" %} {% trans "Issue new card(s)" %}
</a> </a>
{% endif %} {% endif %}
......
{% 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 %}
{% 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 %}
...@@ -5,7 +5,7 @@ from . import api, views ...@@ -5,7 +5,7 @@ from . import api, views
urlpatterns = [ urlpatterns = [
path("test", views.TestPDFView.as_view(), name="test_pdf"), path("test", views.TestPDFView.as_view(), name="test_pdf"),
path("cards/", views.CardListView.as_view(), name="cards"), 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>/", views.CardDetailView.as_view(), name="card"),
path( path(
"cards/<int:pk>/generate_pdf/", "cards/<int:pk>/generate_pdf/",
......
import json import json
from django.contrib import messages 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.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
...@@ -9,20 +10,28 @@ from django.utils.translation import gettext as _ ...@@ -9,20 +10,28 @@ from django.utils.translation import gettext as _
from django.views import View from django.views import View
from django.views.generic.detail import DetailView, SingleObjectMixin 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 reversion.views import RevisionMixin
from rules.contrib.views import PermissionRequiredMixin from rules.contrib.views import PermissionRequiredMixin
from aleksis.apps.kort.forms import ( from aleksis.apps.kort.forms import (
CardForm, CardIssueFinishForm,
CardIssueForm,
CardLayoutForm, CardLayoutForm,
CardLayoutMediaFileFormSet, CardLayoutMediaFileFormSet,
CardPrinterForm, CardPrinterForm,
PrinterSelectForm, PrinterSelectForm,
) )
from aleksis.apps.kort.models import Card, CardLayout, CardPrinter, PrintStatus 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.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
from aleksis.core.models import Person
from aleksis.core.util.celery_progress import render_progress_page from aleksis.core.util.celery_progress import render_progress_page
from aleksis.core.views import RenderPDFView from aleksis.core.views import RenderPDFView
...@@ -60,37 +69,93 @@ class CardListView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, S ...@@ -60,37 +69,93 @@ class CardListView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, S
return Card.objects.order_by("-pk") return Card.objects.order_by("-pk")
class CardCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView): class CardIssueView(PermissionRequiredMixin, RevisionMixin, CookieWizardView):
"""View used to create a card.""" """View used to issue one or more cards."""
permission_required = "core.create_card_rule" permission_required = "core.create_card_rule"
context_object_name = "application" context_object_name = "application"
template_name = "kort/card/create.html" template_name = "kort/card/issue.html"
form_class = CardForm form_list = [CardIssueForm, CardIssueFinishForm]
success_message = _("The card has been created successfully.") success_message = _("The cards have been created successfully.")
success_url = reverse_lazy("cards") success_url = reverse_lazy("cards")
def form_valid(self, form: CardForm) -> HttpResponse: def _get_data(self) -> dict[str, any]:
response = super().form_valid(form) return self.get_cleaned_data_for_step("0")
if form.cleaned_data.get("printer"):
printer = form.cleaned_data["printer"] def _get_persons(self) -> QuerySet:
try: """Get all persons selected in the first step."""
job = self.object.print_card(printer) return self._get_data()["all_persons"]
messages.success(
self.request, def get_form_initial(self, step: str) -> dict[str, any]:
_( if step == "1":
"The print job #{} for the card {} on " return {"persons": self._get_persons()}
"the printer {} has been created successfully." return super().get_form_initial(step)
).format(job.pk, self.object.person, printer.name),
) def get_form_kwargs(self, step: str = None) -> dict[str, any]:
except ValueError as e: kwargs = super().get_form_kwargs(step)
messages.error( if step == "1":
self.request, kwargs["queryset"] = self._get_persons()
_( return kwargs
"The print job couldn't be started because of the following error: {}"
).format(e), def get_form_prefix(self, step: str = None, form: Form = None):
) prefix = super().get_form_prefix(step, form)
return response 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): class CardDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
......
...@@ -28,6 +28,7 @@ python = "^3.9" ...@@ -28,6 +28,7 @@ python = "^3.9"
aleksis-core = "^2.8" aleksis-core = "^2.8"
python-barcode = "^0.14.0" python-barcode = "^0.14.0"
django-ace = "^1.0.12" django-ace = "^1.0.12"
django-formtools = "^2.3"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
aleksis-builddeps = "*" aleksis-builddeps = "*"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment