diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..6036332b97911d095c50df5b7fec2078f5f5435f --- /dev/null +++ b/aleksis/apps/kort/forms.py @@ -0,0 +1,21 @@ +from django import forms + +from django_select2.forms import ModelSelect2Widget + +from aleksis.apps.kort.models import Card + + +class CardForm(forms.ModelForm): + class Meta: + model = Card + fields = ["person", "valid_until"] + widgets = { + "person": ModelSelect2Widget( + search_fields=[ + "first_name__icontains", + "last_name__icontains", + "short_name__icontains", + ], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + } diff --git a/aleksis/apps/kort/menus.py b/aleksis/apps/kort/menus.py index 4ba211e7c0d4ebb96480114bd3b7b030e52601f7..a28ff3e279d238bd3d3bfe6447015beb11be7bb6 100644 --- a/aleksis/apps/kort/menus.py +++ b/aleksis/apps/kort/menus.py @@ -3,14 +3,27 @@ from django.utils.translation import ugettext_lazy as _ MENUS = { "NAV_MENU_CORE": [ { - "name": _("Kort"), - "url": "test_pdf", + "name": _("Student ID Cards"), + "url": "#", "root": True, + "icon": "credit_card", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", ], - "submenu": [], + "submenu": [ + { + "name": _("All Cards"), + "url": "cards", + "icon": "list", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "core.view_cards_rule", + ) + ], + }, + ], } ] } diff --git a/aleksis/apps/kort/migrations/0003_auto_20220308_2023.py b/aleksis/apps/kort/migrations/0003_auto_20220308_2023.py new file mode 100644 index 0000000000000000000000000000000000000000..7f756afa38d142aed0e775db89ee7eff84022d8d --- /dev/null +++ b/aleksis/apps/kort/migrations/0003_auto_20220308_2023.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.12 on 2022-03-08 19:23 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('kort', '0002_card_printer'), + ] + + operations = [ + migrations.RemoveField( + model_name='card', + name='print_finished_at', + ), + migrations.RemoveField( + model_name='card', + name='print_started_at', + ), + migrations.RemoveField( + model_name='card', + name='printed_with', + ), + migrations.CreateModel( + name='CardLayout', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('template', models.TextField(verbose_name='Template')), + ('css', models.TextField(blank=True, verbose_name='Custom CSS')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + ] diff --git a/aleksis/apps/kort/migrations/0004_alter_card_chip_number.py b/aleksis/apps/kort/migrations/0004_alter_card_chip_number.py new file mode 100644 index 0000000000000000000000000000000000000000..e99dd1d3d5cc7e9da3915ab3e5068d0aeef14208 --- /dev/null +++ b/aleksis/apps/kort/migrations/0004_alter_card_chip_number.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-03-08 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0003_auto_20220308_2023'), + ] + + operations = [ + migrations.AlterField( + model_name='card', + name='chip_number', + field=models.CharField(blank=True, max_length=255, verbose_name='Chip Number'), + ), + ] diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py index 478acd97e5b63ec9b3a1a231b0ff011cdc1ad081..a81d6ed37fecd18dc24b1e58917f163579147a31 100644 --- a/aleksis/apps/kort/models.py +++ b/aleksis/apps/kort/models.py @@ -1,10 +1,12 @@ +from django.core.exceptions import ValidationError from django.db import models +from django.template import Context, Template +from django.utils import timezone from django.utils.translation import gettext as _ from aleksis.core.mixins import ExtensibleModel from aleksis.core.models import Person -from django.template import Template, Context -from django.core.exceptions import ValidationError + class CardPrinterStatus(models.TextChoices): ONLINE = "online", _("Online") @@ -42,20 +44,15 @@ class CardLayout(ExtensibleModel): template = models.TextField(verbose_name=_("Template")) css = models.TextField(verbose_name=_("Custom CSS"), blank=True) - def get_template(self) -> Template: return Template(self.template) - - def render(self, card: "Card"): t = self.get_template() context = card.get_context() return t.render(Context(context)) - - def validate_template(self): try: t = Template(self.template) @@ -64,36 +61,23 @@ class CardLayout(ExtensibleModel): raise ValidationError(_("Template is invalid: {}").format(e)) - - - class Card(ExtensibleModel): person = models.ForeignKey( Person, models.CASCADE, verbose_name=_("Person"), related_name="cards" ) - chip_number = models.IntegerField(verbose_name=_("Chip Number")) + chip_number = models.CharField(verbose_name=_("Chip Number"), blank=True, max_length=255) valid_until = models.DateField(verbose_name=_("Valid until")) deactivated = models.BooleanField(verbose_name=_("Deactivated"), default=False) - # Print status - printed_with = models.ForeignKey( - CardPrinter, - on_delete=models.SET_NULL, - blank=True, - null=True, - verbose_name=_("Printed with"), - ) - print_started_at = models.DateTimeField(verbose_name=_("Printed at"), blank=True, null=True) - print_finished_at = models.DateTimeField(verbose_name=_("Printed at"), blank=True, null=True) - @property - def print_status(self) -> PrintStatus: - if self.print_finished_at: - return PrintStatus.FINISHED - elif self.print_started_at: - return PrintStatus.IN_PROGRESS - else: - return PrintStatus.REGISTERED + def is_valid(self): + return ( + self.valid_until <= timezone.now().date() and not self.deactivated and self.chip_number + ) + + def deactivate(self): + self.deactivated = True + self.save() def get_context(self): return { diff --git a/aleksis/apps/kort/rules.py b/aleksis/apps/kort/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..706449103aa3f0c4e729fb8f5a46814ba84bd769 --- /dev/null +++ b/aleksis/apps/kort/rules.py @@ -0,0 +1 @@ +# TBD diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..635102ccf613f0bca82da4ab8b39077bb36bfb3e --- /dev/null +++ b/aleksis/apps/kort/tables.py @@ -0,0 +1,30 @@ +from django.template.loader import render_to_string +from django.utils.translation import gettext as _ + +from django_tables2 import A, BooleanColumn, Column, RelatedLinkColumn, Table + + +class CardTable(Table): + """Table to list cards.""" + + class Meta: + attrs = {"class": "highlight"} + + person = RelatedLinkColumn() + chip_number = Column(verbose_name=_("Chip number")) + current_status = Column(verbose_name=_("Current status"), accessor=A("pk")) + valid_until = Column(verbose_name=_("Valid until")) + deactivated = BooleanColumn(verbose_name=_("Deactivated")) + actions = Column(verbose_name=_("Actions"), accessor=A("pk")) + + def render_current_status(self, value, record): + return render_to_string( + "components/materialize-chips.html", + dict( + content=_("Valid") if record.is_valid else _("Not valid"), + classes="white-text " + ("green" if record.is_valid else "red"), + ), + ) + + def render_actions(self, value, record): + return render_to_string("kort/card/actions.html", dict(pk=value, card=record)) diff --git a/aleksis/apps/kort/templates/kort/card/actions.html b/aleksis/apps/kort/templates/kort/card/actions.html new file mode 100644 index 0000000000000000000000000000000000000000..1c39b5daffc6cc26d456c1d4c05c44f97214bde7 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/actions.html @@ -0,0 +1,50 @@ +{% load i18n %} +<!-- Modal Structure --> +<div id="deactivate-modal-{{ card.pk }}" class="modal"> + <div class="modal-content"> + <h4>{% trans "Do you really want to deactivate the following card?" %}</h4> + {% include "kort/card/short.html" %} + </div> + <div class="modal-footer"> + <a href="#!" class="modal-close waves-effect waves-green btn-flat"> + <i class="material-icons left">close</i> + {% trans "Close" %} + </a> + <a href="{% url "deactivate_card" card.pk %}" class="modal-close waves-effect waves-light orange btn"> + <i class="material-icons left">timer_off</i> + {% trans "Deactivate" %} + </a> + </div> +</div> +<div id="delete-modal-{{ card.pk }}" class="modal"> + <div class="modal-content"> + <h4>{% trans "Do you really want to delete the following card?" %}</h4> + {% include "kort/card/short.html" %} + <figure class="alert warning"> + <i class="material-icons left">warning</i> + {% blocktrans %} + Please pay attention that a deletion of a card is irreversible and should be only used to clean up misprints. + If you just want to make a card unusable because a student has lost his card or left the school, + please deactivate the card instead. + {% endblocktrans %} + </figure> + </div> + <div class="modal-footer"> + <a href="#!" class="modal-close waves-effect waves-green btn-flat"> + <i class="material-icons left">close</i> + {% trans "Close" %} + </a> + <a href="{% url "delete_card" card.pk %}" class="modal-close waves-effect waves-light red btn"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} + </a> + </div> +</div> +<a class="btn-flat waves-effect waves-orange orange-text modal-trigger" href="#deactivate-modal-{{ card.pk }}"> + <i class="material-icons left">timer_off</i> + {% trans "Deactivate" %} +</a> +<a class="btn-flat waves-effect waves-red red-text modal-trigger" href="#delete-modal-{{ card.pk }}"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} +</a> \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/card/create.html b/aleksis/apps/kort/templates/kort/card/create.html new file mode 100644 index 0000000000000000000000000000000000000000..086400b8641fa9bf9c1d6118c8a161f266456254 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/create.html @@ -0,0 +1,24 @@ +{# -*- 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/delete.html b/aleksis/apps/kort/templates/kort/card/delete.html new file mode 100644 index 0000000000000000000000000000000000000000..f15a2df3f7ebdaec360450b8d149f5f24de3a9e5 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/delete.html @@ -0,0 +1,33 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules material_form %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Delete Card{% endblocktrans %}{% endblock %} +{% block no_page_title %}{% endblock %} + +{% block content %} + <p class="flow-text">{% trans "Do you really want to delete the following card?" %}</p> + {% include "kort/card/short.html" %} + <figure class="alert warning"> + <i class="material-icons left">warning</i> + {% blocktrans %} + Please pay attention that a deletion of a card is irreversible and should be only used to clean up misprints. + If you just want to make a card unusable because a student has lost his card or left the school, + please deactivate the card instead. + {% endblocktrans %} + </figure> + <form method="post" action=""> + {% csrf_token %} + <a href="{% url "cards" %}" class="modal-close waves-effect waves-green btn"> + <i class="material-icons left">arrow_back</i> + {% trans "Go back" %} + </a> + <button type="submit" name="delete" class="modal-close waves-effect waves-light red btn"> + <i class="material-icons left">delete</i> + {% trans "Delete" %} + </button> + </form> +{% endblock %} \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/card/edit.html b/aleksis/apps/kort/templates/kort/card/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..51e1cdbfbbc1245eea4f06a96c4da22deeeed3ca --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/edit.html @@ -0,0 +1,24 @@ +{# -*- 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 %}Edit card{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Edit 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/list.html b/aleksis/apps/kort/templates/kort/card/list.html new file mode 100644 index 0000000000000000000000000000000000000000..2a6524b30b7ea87dd108806c21ce27360e2011ea --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/list.html @@ -0,0 +1,40 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules material_form %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Cards{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Cards{% endblocktrans %}{% endblock %} + +{% block content %} + + <script> + + </script> + + + {% has_perm 'core.create_card_rule' user person as can_create_person %} + + {% if can_create_person %} + <a class="btn green waves-effect waves-light" href="{% url 'create_card' %}"> + <i class="material-icons left">add</i> + {% trans "Issue new card" %} + </a> + {% endif %} + + {# <h2>{% trans "Filter persons" %}</h2>#} + {# <form method="get">#} + {# {% form form=persons_filter.form %}{% endform %}#} + {# {% trans "Search" as caption %}#} + {# {% include "core/partials/save_button.html" with caption=caption icon="search" %}#} + {# <button type="reset" class="btn red waves-effect waves-light">#} + {# <i class="material-icons left">clear</i>#} + {# {% trans "Clear" %}#} + {# </button>#} + {# </form>#} + + {# <h2>{% trans "Selected persons" %}</h2>#} + {% render_table table %} +{% endblock %} diff --git a/aleksis/apps/kort/templates/kort/card/short.html b/aleksis/apps/kort/templates/kort/card/short.html new file mode 100644 index 0000000000000000000000000000000000000000..4d533c4ec1980ffe319641aba483dc6081462376 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/short.html @@ -0,0 +1,15 @@ +{% load i18n %} +<table> + <tr> + <th>{% trans "Person" %}</th> + <td>{{ card.person }}</td> + </tr> + <tr> + <th>{% trans "Chip number" %}</th> + <td>{{ card.chip_number }}</td> + </tr> + <tr> + <th>{% trans "Valid until" %}</th> + <td>{{ card.valid_until }}</td> + </tr> +</table> \ No newline at end of file diff --git a/aleksis/apps/kort/templatetags/barcode.py b/aleksis/apps/kort/templatetags/barcode.py index eced25e90ff18297964825f8a00e86aaaae2727d..9468d1dc8a32aea23371afc27f39fe9a844a7364 100644 --- a/aleksis/apps/kort/templatetags/barcode.py +++ b/aleksis/apps/kort/templatetags/barcode.py @@ -1,8 +1,9 @@ +from io import BytesIO + from django import template +from django.utils.safestring import mark_safe -from io import BytesIO import barcode -from django.utils.safestring import mark_safe register = template.Library() @@ -11,8 +12,10 @@ register = template.Library() def generate_barcode(uid): rv = BytesIO() writer = barcode.writer.SVGWriter() - code = barcode.get('code128', uid, writer=writer) - code.write(rv, options={"module_height": 5, "module_width": 0.3, "text_distance": 2, "font_size": 6}) + code = barcode.get("code128", uid, writer=writer) + code.write( + rv, options={"module_height": 5, "module_width": 0.3, "text_distance": 2, "font_size": 6} + ) rv.seek(0) # get rid of the first bit of boilerplate @@ -23,4 +26,3 @@ def generate_barcode(uid): # read the svg tag into a string svg = rv.read() return mark_safe(svg.decode("utf-8")) - diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py index 3a6cacd6bbca01f7527ba4db5e9839e86b98cdda..8aa00d73f70c3c65d37b862b93d9c9b75f52db55 100644 --- a/aleksis/apps/kort/urls.py +++ b/aleksis/apps/kort/urls.py @@ -4,4 +4,8 @@ from . import 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/<int:pk>/deactivate/", views.CardDeactivateView.as_view(), name="deactivate_card"), + path("cards/<int:pk>/delete/", views.CardDeleteView.as_view(), name="delete_card"), ] diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py index f16addae7999e9b395aa28850eae9070ff4abd1d..bfe584c9663db438e57725eb1fb1488ec92a24a4 100644 --- a/aleksis/apps/kort/views.py +++ b/aleksis/apps/kort/views.py @@ -1,14 +1,20 @@ -from aleksis.core.views import RenderPDFView -from django.contrib.auth.decorators import login_required +from django.contrib import messages from django.http import HttpRequest, HttpResponse -from django.shortcuts import render - +from django.shortcuts import redirect, render +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from django.views import View +from django.views.generic.detail import SingleObjectMixin -@login_required -def empty(request: HttpRequest) -> HttpResponse: - context = {} +from django_tables2 import SingleTableView +from reversion.views import RevisionMixin +from rules.contrib.views import PermissionRequiredMixin - return render(request, "kort/empty.html", context) +from aleksis.apps.kort.forms import CardForm +from aleksis.apps.kort.models import Card +from aleksis.apps.kort.tables import CardTable +from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView +from aleksis.core.views import RenderPDFView class TestPDFView(RenderPDFView): @@ -21,4 +27,48 @@ class TestPDFView(RenderPDFView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["title"] = "Test PDF" - return context \ No newline at end of file + return context + + +class CardListView(PermissionRequiredMixin, RevisionMixin, SingleTableView): + """List view for all cards.""" + + permission_required = "core.view_cards_rule" + template_name = "kort/card/list.html" + model = Card + table_class = CardTable + + +class CardCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView): + """View used to create a card.""" + + 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.") + success_url = reverse_lazy("cards") + + +class CardDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): + """View used to delete a card.""" + + permission_required = "core.delete_card_rule" + success_url = reverse_lazy("cards") + template_name = "kort/card/delete.html" + model = Card + success_message = _("The card has been deleted successfully.") + + +class CardDeactivateView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View): + """View used to deactivate a card.""" + + permission_required = "core.delete_card_rule" + model = Card + success_url = reverse_lazy("cards") + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + self.object.deactivate() + messages.success(request, _("The card has been deactivated successfully.")) + return redirect(self.success_url)