diff --git a/aleksis/apps/kort/api.py b/aleksis/apps/kort/api.py new file mode 100644 index 0000000000000000000000000000000000000000..5179acf9a46240b18110decf532ea94eeee95ab5 --- /dev/null +++ b/aleksis/apps/kort/api.py @@ -0,0 +1,182 @@ +from django.contrib import admin +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from celery.result import allow_join_result +from celery.states import SUCCESS +from oauth2_provider.contrib.rest_framework import TokenHasScope +from rest_framework import generics, permissions, serializers +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.permissions import BasePermission +from rest_framework.response import Response +from rest_framework.views import APIView + +from aleksis.apps.kort.models import Card, CardPrinter, CardPrintJob + +admin.autodiscover() + + +class CorrectPrinterPermission(BasePermission): + """Check whether the OAuth2 application belongs to the printer.""" + + def has_object_permission(self, request, view, obj) -> bool: + token = request.auth + if token.application == obj.oauth2_application: + return True + return False + + +class CorrectJobPrinterPermission(BasePermission): + """Check whether the OAuth2 application belongs to the job's printer.""" + + def has_object_permission(self, request, view, obj) -> bool: + token = request.auth + if token.application == obj.printer.oauth2_application: + return True + return False + + +class CardPrinterSerializer(serializers.ModelSerializer): + class Meta: + model = CardPrinter + fields = ( + "id", + "name", + "description", + "location", + "status", + "status_label", + "status_color", + "status_icon", + "status_text", + "last_seen_at", + "cups_printer", + "generate_number_on_server", + "card_detector", + ) + + +class CardPrinterStatusSerializer(serializers.ModelSerializer): + class Meta: + model = CardPrinter + fields = ("status", "status_text") + + +class CardSerializer(serializers.ModelSerializer): + class Meta: + model = Card + fields = ("id", "chip_number", "valid_until", "deactivated", "person", "pdf_file") + + +class CardPrintJobSerializer(serializers.ModelSerializer): + card = CardSerializer() + + class Meta: + model = CardPrintJob + fields = ("id", "printer", "card", "status", "status_text") + + +class CardPrintJobStatusSerializer(serializers.ModelSerializer): + class Meta: + model = CardPrintJob + fields = ("id", "status", "status_text") + + +class CardChipNumberSerializer(serializers.ModelSerializer): + class Meta: + model = Card + fields = ("chip_number",) + + +class CardPrinterDetails(generics.RetrieveAPIView): + """Show details about the card printer.""" + + permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] + required_scopes = ["card_printer"] + serializer_class = CardPrinterSerializer + queryset = CardPrinter.objects.all() + + def get_object(self): + token = self.request.auth + return token.application.card_printers.all().first() + + +class CardPrinterUpdateStatus(generics.UpdateAPIView): + """Update the status of the card printer.""" + + permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] + required_scopes = ["card_printer"] + serializer_class = CardPrinterStatusSerializer + queryset = CardPrinter.objects.all() + + def update(self, request, *args, **kwargs): + r = super().update(request, *args, **kwargs) + instance = self.get_object() + instance.last_seen_at = timezone.now() + instance.save() + return r + + +class GetNextPrintJob(APIView): + """Get the next print job.""" + + permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] + required_scopes = ["card_printer"] + serializer_class = CardPrinterSerializer + queryset = CardPrinter.objects.all() + + def get_object(self, pk): + return get_object_or_404(CardPrinter, pk=pk) + + def get(self, request, pk, *args, **kwargs): + printer = self.get_object(pk) + job = printer.get_next_print_job() + if not job: + return Response({"status": "no_job"}) + serializer = CardPrintJobSerializer(job) + return Response(serializer.data) + + +class CardPrintJobUpdateStatusView(generics.UpdateAPIView): + """Update the status of the card printer.""" + + permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission] + required_scopes = ["card_printer"] + serializer_class = CardPrintJobStatusSerializer + queryset = CardPrintJob.objects.all() + + +class CardPrintJobSetChipNumberView(generics.UpdateAPIView): + """Update the status of the card printer.""" + + permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission] + required_scopes = ["card_printer"] + serializer_class = CardChipNumberSerializer + queryset = CardPrintJob.objects.all() + + def update(self, request, *args, **kwargs): + instance = self.get_object() + card = instance.card + + if card.chip_number: + raise ValidationError + + serializer = self.get_serializer(card, data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + result = instance.card.generate_pdf() + + with allow_join_result(): + result.wait() + card.refresh_from_db() + + if result.status == SUCCESS and card.pdf_file: + serializer = CardPrintJobSerializer(instance) + instance.refresh_from_db() + + return Response(serializer.data) + else: + card.chip_number = None + card.save() + raise APIException("Error while generating PDF file") diff --git a/aleksis/apps/kort/apps.py b/aleksis/apps/kort/apps.py index 797ede1bc9d1a9db4456910879452dccfc6bdf85..437a5bb8e682058629a89254713ccd3fba0a1468 100644 --- a/aleksis/apps/kort/apps.py +++ b/aleksis/apps/kort/apps.py @@ -1,6 +1,3 @@ -from django.apps import apps -from django.db import models -from django.db.models import functions from django.utils.translation import gettext as _ from aleksis.core.util.apps import AppConfig @@ -25,18 +22,4 @@ class DefaultConfig(AppConfig): @classmethod def get_all_scopes(cls) -> dict[str, str]: """Return all OAuth scopes and their descriptions for this app.""" - Card = apps.get_model("kort", "Card") - label_prefix = _("Access and manage printer status and print jobs") - scopes = dict( - Card.objects.annotate( - scope=functions.Concat( - models.Value(f"{Card.SCOPE_PREFIX}_"), - models.F("pk"), - output_field=models.CharField(), - ), - label=functions.Concat(models.Value(f"{label_prefix}: "), models.F("name")), - ) - .values_list("scope", "label") - .distinct() - ) - return scopes + return {"card_printer": _("Access and manage printer status and print jobs")} diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py index 6036332b97911d095c50df5b7fec2078f5f5435f..6b947e78e04f8173a1b7d372746b9cfac73a4d63 100644 --- a/aleksis/apps/kort/forms.py +++ b/aleksis/apps/kort/forms.py @@ -1,8 +1,10 @@ from django import forms +from django.utils.translation import gettext as _ from django_select2.forms import ModelSelect2Widget +from material import Fieldset, Layout -from aleksis.apps.kort.models import Card +from aleksis.apps.kort.models import Card, CardPrinter class CardForm(forms.ModelForm): @@ -19,3 +21,35 @@ class CardForm(forms.ModelForm): attrs={"data-minimum-input-length": 0, "class": "browser-default"}, ), } + + +class CardPrinterForm(forms.ModelForm): + layout = Layout( + Fieldset(_("Generic attributes"), "name", "location", "description"), + Fieldset( + _("Printer settings"), "cups_printer", "generate_number_on_server", "card_detector" + ), + ) + + class Meta: + model = CardPrinter + fields = [ + "name", + "location", + "description", + "cups_printer", + "generate_number_on_server", + "card_detector", + ] + + +class PrinterSelectForm(forms.Form): + layout = Layout("printer") + printer = forms.ModelChoiceField(queryset=None, label=_("Card Printer"), required=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + printers = CardPrinter.objects.all() + self.fields["printer"].queryset = printers + if printers.count() == 1: + self.fields["printer"].initial = printers.first() diff --git a/aleksis/apps/kort/menus.py b/aleksis/apps/kort/menus.py index 812fb3894999177feb8f04c7b2644c635756e5e1..2116a83244bdcf51f4870a6b21c3c193d4cd8fb6 100644 --- a/aleksis/apps/kort/menus.py +++ b/aleksis/apps/kort/menus.py @@ -23,6 +23,17 @@ MENUS = { ) ], }, + { + "name": _("Card Printers"), + "url": "card_printers", + "svg_icon": "mdi:printer-outline", + "validators": [ + ( + "aleksis.core.util.predicates.permission_validator", + "core.view_cardprinters_rule", + ) + ], + }, ], } ] diff --git a/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py b/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py new file mode 100644 index 0000000000000000000000000000000000000000..5a5cef67ea2067c06c8d555f5c0f60996ec8c6e9 --- /dev/null +++ b/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.12 on 2022-03-15 18:57 + +from django.conf import settings +import django.contrib.sites.managers +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ('sites', '0002_alter_domain_unique'), + ('kort', '0006_auto_20220310_2003'), + ] + + operations = [ + migrations.AlterField( + model_name='card', + name='pdf_file', + field=models.FileField(blank=True, default='', upload_to='cards/', validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='PDF file'), + preserve_default=False, + ), + migrations.AlterField( + model_name='cardprinter', + name='oauth2_application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='card_printers', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, verbose_name='OAuth2 application'), + ), + migrations.CreateModel( + name='CardPrintJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('status', models.CharField(choices=[('registered', 'Registered'), ('in_progress', 'In progress'), ('finished', 'Finished')], default='registered', max_length=255, verbose_name='Status')), + ('status_text', models.TextField(blank=True, verbose_name='Status text')), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kort.card', verbose_name='Card')), + ('printer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kort.cardprinter', verbose_name='Printer')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Card print job', + 'verbose_name_plural': 'Card print jobs', + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + ] diff --git a/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py b/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py new file mode 100644 index 0000000000000000000000000000000000000000..d350d51ecefc8d0a89df3ae9516d355b94f8c38d --- /dev/null +++ b/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.12 on 2022-03-19 19:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0007_auto_20220315_1957'), + ] + + operations = [ + migrations.AddField( + model_name='cardprinter', + name='cups_printer', + field=models.CharField(blank=True, max_length=255, verbose_name='CUPS printer'), + ), + migrations.AlterField( + model_name='cardprintjob', + name='printer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='kort.cardprinter', verbose_name='Printer'), + ), + migrations.AlterField( + model_name='cardprintjob', + name='status', + field=models.CharField(choices=[('registered', 'Registered'), ('in_progress', 'In progress'), ('finished', 'Finished'), ('failed', 'Failed')], default='registered', max_length=255, verbose_name='Status'), + ), + ] diff --git a/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py b/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py new file mode 100644 index 0000000000000000000000000000000000000000..e099be6a0247fac83180714d00c8d57424ede663 --- /dev/null +++ b/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-03-26 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0008_auto_20220319_2018'), + ] + + operations = [ + migrations.AddField( + model_name='cardprinter', + name='generate_number_on_server', + field=models.BooleanField(default=True, verbose_name='Generate card number on server'), + ), + ] diff --git a/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py b/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py new file mode 100644 index 0000000000000000000000000000000000000000..028d25cd9ed0f4cf1d6ea27e3dfb541e53054755 --- /dev/null +++ b/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.12 on 2022-03-26 20:23 + +from django.db import migrations +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0009_cardprinter_generate_number_on_server'), + ] + + operations = [ + migrations.AddField( + model_name='cardprintjob', + name='created', + field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'), + ), + migrations.AddField( + model_name='cardprintjob', + name='modified', + field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'), + ), + ] diff --git a/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py b/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..4a62022f08deff9b7d61b4d88d70c529486607b6 --- /dev/null +++ b/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-04-11 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0010_auto_20220326_2123'), + ] + + operations = [ + migrations.AddField( + model_name='cardprinter', + name='card_detector', + field=models.CharField(blank=True, max_length=255, verbose_name='Card detector'), + ), + ] diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py index 11e2e03d521202339c1c5f155b6a05d3b97ee60d..eac8c3bf0211296c6f6d34704ace0db9eb3bb505 100644 --- a/aleksis/apps/kort/models.py +++ b/aleksis/apps/kort/models.py @@ -1,13 +1,18 @@ -from typing import Union +import uuid +from datetime import timedelta +from typing import Any, Optional, Union +from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import FileExtensionValidator from django.db import models +from django.db.models import Q from django.template import Context, Template from django.utils import timezone from django.utils.translation import gettext as _ from celery.result import AsyncResult +from model_utils.models import TimeStampedModel from aleksis.core.mixins import ExtensibleModel from aleksis.core.models import OAuthApplication, Person @@ -19,11 +24,37 @@ class CardPrinterStatus(models.TextChoices): WITH_ERRORS = "with_errors", _("With errors") NOT_REGISTERED = "not_registered", _("Not registered") + @classmethod + def get_color(cls, value): + _colors = { + CardPrinterStatus.ONLINE.value: "green", + CardPrinterStatus.OFFLINE.value: "red", + CardPrinterStatus.WITH_ERRORS.value: "orange", + CardPrinterStatus.NOT_REGISTERED.value: "grey", + } + return _colors.get(value) + + @classmethod + def get_icon(cls, value): + _icons = { + CardPrinterStatus.ONLINE.value: "mdi:printer-check", + CardPrinterStatus.OFFLINE.value: "mdi:printer-off", + CardPrinterStatus.WITH_ERRORS.value: "mdi:printer-alert", + CardPrinterStatus.NOT_REGISTERED.value: "mdi:printer-search", + } + return _icons.get(value) + + @classmethod + def get_label(cls, value): + _labels = {x: y for x, y in cls.choices} + return _labels.get(value) + class PrintStatus(models.TextChoices): REGISTERED = "registered", _("Registered") IN_PROGRESS = "in_progress", _("In progress") FINISHED = "finished", _("Finished") + FAILED = "failed", _("Failed") class CardPrinter(ExtensibleModel): @@ -47,14 +78,23 @@ class CardPrinter(ExtensibleModel): verbose_name=_("OAuth2 application"), blank=True, null=True, + related_name="card_printers", ) + # Settings + cups_printer = models.CharField(max_length=255, verbose_name=_("CUPS printer"), blank=True) + generate_number_on_server = models.BooleanField( + default=True, verbose_name=_("Generate card number on server") + ) + card_detector = models.CharField(max_length=255, verbose_name=_("Card detector"), blank=True) + def save(self, *args, **kwargs): if not self.oauth2_application: application = OAuthApplication( client_type=OAuthApplication.CLIENT_CONFIDENTIAL, authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE, name=f"Card printer: {self.name}", + redirect_uris="urn:ietf:wg:oauth:2.0:oob", ) application.save() self.oauth2_application = application @@ -64,6 +104,73 @@ class CardPrinter(ExtensibleModel): def __str__(self): return self.name + @property + def status_label(self) -> str: + """Return the verbose name of the status.""" + return CardPrinterStatus.get_label(self.status) + + @property + def status_color(self) -> str: + """Return a color for the status.""" + return CardPrinterStatus.get_color(self.status) + + @property + def status_icon(self) -> str: + """Return an iconify icon for the status.""" + return CardPrinterStatus.get_icon(self.status) + + def generate_config(self) -> dict[str, Any]: + """Generate the configuration for the printer client.""" + config = { + "base_url": settings.BASE_URL, + "client_id": self.oauth2_application.client_id, + "client_secret": self.oauth2_application.client_secret, + } + return config + + @property + def config_filename(self) -> str: + """Return the filename for the printer client configuration.""" + return f"card-printer-config-{self.pk}.json" + + def check_online_status(self): + if ( + self.status + not in (CardPrinterStatus.NOT_REGISTERED.value, CardPrinterStatus.OFFLINE.value) + and self.last_seen_at + ): + if self.last_seen_at < timezone.now() - timedelta(minutes=1): + self.status = CardPrinterStatus.OFFLINE.value + self.save() + + @classmethod + def check_online_status_for_all(cls): + for printer in cls.objects.all(): + printer.check_online_status() + + def get_next_print_job(self) -> Optional["CardPrintJob"]: + if not self.generate_number_on_server: + print( + self.jobs.filter(card__pdf_file=""), + self.jobs.filter(card__pdf_file="").exclude(card__chip_number=""), + ) + self.jobs.filter( + (Q(card__pdf_file="") & ~Q(card__chip_number="")) + | Q(status=PrintStatus.IN_PROGRESS) + ).update(status=PrintStatus.FAILED) + Card.objects.filter( + jobs__in=self.jobs.filter(status=PrintStatus.FAILED), chip_number="" + ).update(chip_number="") + else: + self.jobs.filter(status=PrintStatus.IN_PROGRESS).update(status=PrintStatus.FAILED) + + jobs = self.jobs.order_by("created").filter(status=PrintStatus.REGISTERED) + if self.generate_number_on_server: + jobs = jobs.filter(card__pdf_file__isnull=False) + if jobs.exists(): + return jobs.first() + return None + class Meta: verbose_name = _("Card printer") verbose_name_plural = _("Card printers") @@ -130,6 +237,19 @@ class Card(ExtensibleModel): return True return generate_card_pdf.delay(self.pk) + def print_card(self, printer: CardPrinter): + job = CardPrintJob(card=self, printer=printer) + job.save() + if not self.chip_number and printer.generate_number_on_server: + self.chip_number = str(self.generate_number()) + self.save() + if self.chip_number: + self.generate_pdf() + return job + + def generate_number(self) -> int: + return uuid.uuid1().int >> 32 + def __str__(self): if self.chip_number: return f"{self.person} ({self.chip_number})" @@ -138,3 +258,24 @@ class Card(ExtensibleModel): class Meta: verbose_name = _("Card") verbose_name_plural = _("Cards") + + +class CardPrintJob(TimeStampedModel, ExtensibleModel): + printer = models.ForeignKey( + CardPrinter, on_delete=models.CASCADE, verbose_name=_("Printer"), related_name="jobs" + ) + card = models.ForeignKey( + Card, on_delete=models.CASCADE, verbose_name=_("Card"), related_name="jobs" + ) + + status = models.CharField( + max_length=255, + verbose_name=_("Status"), + choices=PrintStatus.choices, + default=PrintStatus.REGISTERED, + ) + status_text = models.TextField(verbose_name=_("Status text"), blank=True) + + class Meta: + verbose_name = _("Card print job") + verbose_name_plural = _("Card print jobs") diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py index 5b53edde95c511f73605f25b3adc094ee3b674e6..dedcab6e0dcc93480212de4db94f975ae5344ef2 100644 --- a/aleksis/apps/kort/tables.py +++ b/aleksis/apps/kort/tables.py @@ -1,7 +1,17 @@ from django.template.loader import render_to_string from django.utils.translation import gettext as _ -from django_tables2 import A, BooleanColumn, Column, LinkColumn, RelatedLinkColumn, Table +from django_tables2 import ( + A, + BooleanColumn, + Column, + DateTimeColumn, + LinkColumn, + RelatedLinkColumn, + Table, +) + +from aleksis.apps.kort.forms import PrinterSelectForm class CardTable(Table): @@ -26,4 +36,33 @@ class CardTable(Table): ) def render_actions(self, value, record): - return render_to_string("kort/card/actions.html", dict(pk=value, card=record)) + return render_to_string( + "kort/card/actions.html", dict(pk=value, card=record, printer_form=PrinterSelectForm()) + ) + + +class CardPrinterTable(Table): + """Table to list card printers.""" + + class Meta: + attrs = {"class": "highlight"} + + name = LinkColumn("card_printer", verbose_name=_("Printer name"), args=[A("pk")]) + location = Column(verbose_name=_("Printer location")) + + current_status = Column(verbose_name=_("Current status"), accessor=A("pk")) + last_seen_at = DateTimeColumn(verbose_name=_("Last seen at")) + jobs_count = Column(verbose_name=_("Running jobs")) + + actions = Column(verbose_name=_("Actions"), accessor=A("pk")) + + def render_current_status(self, value, record): + return render_to_string( + "kort/printer/status.html", + dict( + printer=record, + ), + ) + + def render_actions(self, value, record): + return render_to_string("kort/printer/actions.html", dict(pk=value, printer=record)) diff --git a/aleksis/apps/kort/templates/kort/card/detail_content.html b/aleksis/apps/kort/templates/kort/card/detail_content.html index 78f6036b10bc65ac2cba248a1e7cd78d687de970..23b62275f9a177c6e2e0e03230efbdc620aed838 100644 --- a/aleksis/apps/kort/templates/kort/card/detail_content.html +++ b/aleksis/apps/kort/templates/kort/card/detail_content.html @@ -39,9 +39,12 @@ </div> </div> <div class="col s12 m12 l6"> + {% include "kort/card/print_form.html" %} {% if card.pdf_file %} - <div id="card-pdf-{{ card.pk }}" style="height: 500px;"></div> - <script>PDFObject.embed("{{ card.pdf_file.url }}", "#card-pdf-{{ card.pk }}");</script> + <a href="{{ card.pdf_file.url }}" class="btn waves-effect waves-light"> + <i class="material-icons left iconify" data-icon="mdi:file-pdf-box"></i> + {% trans "Show card as PDF" %} + </a> {% else %} <div class="row center-via-flex"> <a class="btn waves-effect waves-light" href="{% url "generate_card_pdf" card.pk %}"> diff --git a/aleksis/apps/kort/templates/kort/card/print_form.html b/aleksis/apps/kort/templates/kort/card/print_form.html new file mode 100644 index 0000000000000000000000000000000000000000..01fef6935f959dcfc523c5c283bd509fdf362b26 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/card/print_form.html @@ -0,0 +1,18 @@ +{% load material_form i18n %} +<form action="{% url "print_card" card.pk %}" method="get"> + <div class="card"> + <div class="card-content"> + <div class="card-title"><i class="material-icons left iconify" + data-icon="mdi:printer-outline"></i> {% trans "Print card" %}</div> + + {% csrf_token %} + {% form form=printer_form %}{% endform %} + </div> + <div class="card-action-light"> + <button type="submit" class="btn waves-effect waves-light"> + <i class="material-icons left iconify" data-icon="mdi:printer-outline"></i> + {% trans "Print card" %} + </button> + </div> + </div> +</form> diff --git a/aleksis/apps/kort/templates/kort/card/status.html b/aleksis/apps/kort/templates/kort/card/status.html index 009baa0bcdea5fbd575d481ae9f2a0efcf9bd7af..dd5fa55c89ba3d28d420b1d7de7444891c139696 100644 --- a/aleksis/apps/kort/templates/kort/card/status.html +++ b/aleksis/apps/kort/templates/kort/card/status.html @@ -2,5 +2,5 @@ {% if card.is_valid %} <span class="badge new green white-text">{% trans "Valid" %}</span> {% else %} - <span class="badge new green white-text">{% trans "Not valid" %}</span> + <span class="badge new red white-text">{% trans "Not valid" %}</span> {% endif %} \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/printer/actions.html b/aleksis/apps/kort/templates/kort/printer/actions.html new file mode 100644 index 0000000000000000000000000000000000000000..74f1bccbaafe048280e28a20e29195c346b1a54f --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/actions.html @@ -0,0 +1,26 @@ +{% load i18n %} + +<div id="detail-modal-{{ printer.pk }}" class="modal"> + <div class="modal-content"> + <h4>{{ printer.name }}</h4> + {% include "kort/printer/detail_content.html" %} + </div> + <div class="modal-footer"> + <a href="#!" class="modal-close waves-effect waves-green btn-flat"> + <i class="material-icons left iconify" data-icon="mdi:close"></i> + {% trans "Close" %} + </a> + </div> +</div> +<a class="btn-flat waves-effect waves-green green-text modal-trigger" href="#detail-modal-{{ printer.pk }}"> + <i class="material-icons left iconify" data-icon="mdi:play-box-outline"></i> + {% trans "Show" %} +</a> +<a class="btn-flat waves-effect waves-orange orange-text modal-trigger" href="{% url "edit_card_printer" printer.pk %}"> + <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i> + {% trans "Edit" %} +</a> +<a class="btn-flat waves-effect waves-red red-text modal-trigger" href="{% url "delete_card_printer" printer.pk %}"> + <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i> + {% trans "Delete" %} +</a> \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/printer/create.html b/aleksis/apps/kort/templates/kort/printer/create.html new file mode 100644 index 0000000000000000000000000000000000000000..086400b8641fa9bf9c1d6118c8a161f266456254 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/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/printer/delete.html b/aleksis/apps/kort/templates/kort/printer/delete.html new file mode 100644 index 0000000000000000000000000000000000000000..9fc32ae6baaede5d13e20316484705ffec3f800f --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/delete.html @@ -0,0 +1,32 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules material_form %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Delete Card Printer{% endblocktrans %}{% endblock %} +{% block no_page_title %}{% endblock %} + +{% block content %} + <p class="flow-text">{% trans "Do you really want to delete the following card printer?" %}</p> + {% include "kort/printer/short.html" with printer=object %} + <figure class="alert warning"> + <i class="material-icons left iconify" data-icon="mdi:alert-octagon-outline"></i> + {% blocktrans %} + Please pay attention that this also will deactivate the access for the print client and you would have to + reconfigure the client if you want to use it further. + {% endblocktrans %} + </figure> + <form method="post" action=""> + {% csrf_token %} + <a href="{% url "card_printers" %}" class="modal-close waves-effect waves-green btn"> + <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i> + {% trans "Go back" %} + </a> + <button type="submit" name="delete" class="modal-close waves-effect waves-light red btn"> + <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i> + {% trans "Delete" %} + </button> + </form> +{% endblock %} \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/printer/detail.html b/aleksis/apps/kort/templates/kort/printer/detail.html new file mode 100644 index 0000000000000000000000000000000000000000..303b6674c9b6941b30b8e3fba54c2b58ae6a7793 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/detail.html @@ -0,0 +1,27 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load material_form i18n any_js %} + + +{% block browser_title %}{% blocktrans %}Card Printer{% endblocktrans %}{% endblock %} +{% block page_title %}{{ object.name }}{% endblock %} + + +{% block content %} + <a class="btn waves-effect waves-light secondary margin-bottom" href="{% url "card_printers" %}"> + <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i> + {% trans "Back to all printers" %} + </a> + <a class="btn waves-effect waves-light margin-bottom orange modal-trigger" + href="{% url "edit_card_printer" object.pk %}"> + <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i> + {% trans "Edit" %} + </a> + <a class="btn waves-effect waves-light margin-bottom red modal-trigger" href="{% url "delete_card_printer" object.pk %}"> + <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i> + {% trans "Delete" %} + </a> + {% include "kort/printer/detail_content.html" with printer=object %} +{% endblock %} diff --git a/aleksis/apps/kort/templates/kort/printer/detail_content.html b/aleksis/apps/kort/templates/kort/printer/detail_content.html new file mode 100644 index 0000000000000000000000000000000000000000..9618a4bf1c16faa0de82b58ae6cae020f6df4200 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/detail_content.html @@ -0,0 +1,113 @@ +{% load i18n %} +<div class="row no-margin"> + <div class="col s12 m12 l6 no-padding"> + <div class="card"> + <div class="card-content"> + <div class="card-title">{% trans "Printer details" %}</div> + <table> + <tr> + <th> + <i class="material-icons left iconify" data-icon="mdi:printer-outline"></i> + {% trans "Name" %}</th> + <td>{{ printer.name }}</td> + </tr> + <tr> + <th> + <i class="material-icons left iconify" data-icon="mdi:map-marker-outline"></i> + {% trans "Location" %} + </th> + <td>{{ printer.location|default:"–" }}</td> + </tr> + <tr> + <th> + <i class="material-icons left iconify" data-icon="mdi:card-text-outline"></i> + {% trans "Description" %} + </th> + <td>{{ printer.description|default:"–" }}</td> + </tr> + <tr> + <th> + <i class="material-icons left iconify" data-icon="mdi:clock-check-outline"></i> + {% trans "Last seen at" %} + </th> + <td>{{ printer.last_seen_at|default:_("never seen yet") }}</td> + </tr> + <tr> + <th {% if printer.status_text %}rowspan="2"{% endif %}> + <i class="material-icons left iconify" data-icon="mdi:checkbox-blank-badge-outline"></i> + {% trans "Status" %} + </th> + <td> + {% include "kort/printer/status.html" %} + </td> + </tr> + {% if printer.status_text %} + <tr> + <td> + {{ printer.status_text }} + </td> + </tr> + {% endif %} + </table> + </div> + </div> + </div> + <div class="col s12 m12 l6"> + <!-- Jobs here --> + {% if printer.status == "not_registered" %} + <div class="card"> + <div class="card-content"> + <div class="card-title">{% trans "Setup printer client" %}</div> + <p> + {% blocktrans %} + To enable printing, you have to register the print client on the device + which the printer is connected to. + {% endblocktrans %} + </p> + <h6>{% trans "1. Download print client" %}</h6> + <h6>{% trans "2. Download configuration file" %}</h6> + <a class="btn waves-effect waves-light" href="{% url "card_printer_config" printer.pk %}"> + <i class="material-icons left iconify" data-icon="mdi:download-outline"></i> + {% trans "Download configuration" %} + </a> + <h6>{% trans "3. Setup client" %}</h6> + <code>kort-client setup {{ printer.config_filename }}</code> + </div> + </div> + {% else %} + <div class="card"> + <div class="card-content"> + <div class="card-title">{% trans "Print jobs" %}</div> + <table> + <tr> + <th> + {% trans "Card" %} + </th> + <th> + {% trans "Created at" %} + </th> + <th> + {% trans "Status" %} + </th> + </tr> + {% for job in printer.jobs.all %} + <tr> + <td> + <a href="{% url "card" job.card.pk %}"> + {{ job.card }} + </a> + </td> + <td> + {{ job.created }} + </td> + <td> + {{ job.status }} {% if job.status_text %}({{ job.status_text }}){% endif %} + </td> + </tr> + {% endfor %} + </table> + </div> + </div> + {% endif %} + </div> +</div> \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/printer/edit.html b/aleksis/apps/kort/templates/kort/printer/edit.html new file mode 100644 index 0000000000000000000000000000000000000000..450a2d022f44dcc3581c681f6279f87500f14185 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/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 printer{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Edit card printer{% 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/printer/list.html b/aleksis/apps/kort/templates/kort/printer/list.html new file mode 100644 index 0000000000000000000000000000000000000000..014df87d08e4f8fdadcc590bca0ebb6fe1b87d77 --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/list.html @@ -0,0 +1,22 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n rules material_form any_js %} +{% load render_table from django_tables2 %} + +{% block browser_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %} + +{% block content %} + {% has_perm 'core.create_cardprinter_rule' user person as can_create_person %} + + {% if can_create_person %} + <a class="btn green waves-effect waves-light" href="{% url 'create_card_printer' %}"> + <i class="material-icons left iconify" data-icon="mdi:plus"></i> + {% trans "Register new card printer" %} + </a> + {% endif %} + + {% render_table table %} +{% endblock %} diff --git a/aleksis/apps/kort/templates/kort/printer/short.html b/aleksis/apps/kort/templates/kort/printer/short.html new file mode 100644 index 0000000000000000000000000000000000000000..706a5e14840f8f5cbaea409a0b76b9f77c50235d --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/short.html @@ -0,0 +1,15 @@ +{% load i18n %} +<table> + <tr> + <th>{% trans "Name" %}</th> + <td>{{ printer.name }}</td> + </tr> + <tr> + <th>{% trans "Location" %}</th> + <td>{{ printer.location|default:"–" }}</td> + </tr> + <tr> + <th>{% trans "Status" %}</th> + <td>{% include "kort/printer/status.html" %}</td> + </tr> +</table> \ No newline at end of file diff --git a/aleksis/apps/kort/templates/kort/printer/status.html b/aleksis/apps/kort/templates/kort/printer/status.html new file mode 100644 index 0000000000000000000000000000000000000000..3c2a3a9f3ecf8acf5544acde08f7e466918e26df --- /dev/null +++ b/aleksis/apps/kort/templates/kort/printer/status.html @@ -0,0 +1,5 @@ +{% load i18n %} +<span class="chip white-text {{ printer.status_color }}" title="{{ printer.status_text }}"> + <i class="material-icons left iconify" data-icon="{{ printer.status_icon }}"></i> + {{ printer.status_label }} +</span> \ No newline at end of file diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py index 983140bc22fee78ee81eddfa486a3ca36faffb92..1cc3abcde975474cf37c389060e2a7dc1ea0544d 100644 --- a/aleksis/apps/kort/urls.py +++ b/aleksis/apps/kort/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from . import views +from . import api, views urlpatterns = [ path("test", views.TestPDFView.as_view(), name="test_pdf"), @@ -13,5 +13,41 @@ urlpatterns = [ name="generate_card_pdf", ), path("cards/<int:pk>/deactivate/", views.CardDeactivateView.as_view(), name="deactivate_card"), + path("cards/<int:pk>/print/", views.CardPrintView.as_view(), name="print_card"), path("cards/<int:pk>/delete/", views.CardDeleteView.as_view(), name="delete_card"), + path("printers/", views.CardPrinterListView.as_view(), name="card_printers"), + path("printers/create/", views.CardPrinterCreateView.as_view(), name="create_card_printer"), + path("printers/<int:pk>/", views.CardPrinterDetailView.as_view(), name="card_printer"), + path("printers/<int:pk>/edit/", views.CardPrinterEditView.as_view(), name="edit_card_printer"), + path( + "printers/<int:pk>/delete/", + views.CardPrinterDeleteView.as_view(), + name="delete_card_printer", + ), + path( + "printers/<int:pk>/config/", + views.CardPrinterConfigView.as_view(), + name="card_printer_config", + ), + path("api/v1/printers/", api.CardPrinterDetails.as_view(), name="api_card_printer"), + path( + "api/v1/printers/<int:pk>/status/", + api.CardPrinterUpdateStatus.as_view(), + name="api_card_printer_status", + ), + path( + "api/v1/printers/<int:pk>/jobs/next/", + api.GetNextPrintJob.as_view(), + name="api_get_next_print_job", + ), + path( + "api/v1/jobs/<int:pk>/status/", + api.CardPrintJobUpdateStatusView.as_view(), + name="api_update_job_status", + ), + path( + "api/v1/jobs/<int:pk>/chip_number/", + api.CardPrintJobSetChipNumberView.as_view(), + name="api_set_chip_number", + ), ] diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py index 1741e6e3bac558d9373d0b35eed4c600b409011c..31a7c42468fe4b31fbdedfc79d763ba7f17086bb 100644 --- a/aleksis/apps/kort/views.py +++ b/aleksis/apps/kort/views.py @@ -1,6 +1,9 @@ +import json + from django.contrib import messages +from django.db.models import Count, Q from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils.translation import gettext as _ from django.views import View @@ -10,10 +13,10 @@ from django_tables2 import SingleTableView from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin -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.apps.kort.forms import CardForm, CardPrinterForm, PrinterSelectForm +from aleksis.apps.kort.models import Card, CardPrinter, PrintStatus +from aleksis.apps.kort.tables import CardPrinterTable, CardTable +from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView from aleksis.core.util.celery_progress import render_progress_page from aleksis.core.views import RenderPDFView @@ -31,7 +34,16 @@ class TestPDFView(RenderPDFView): return context -class CardListView(PermissionRequiredMixin, RevisionMixin, SingleTableView): +class PrinterSelectMixin: + def get_context_data(self, **kwargs): + print("Called and used?") + context = super().get_context_data(**kwargs) + context["printers"] = CardPrinter.objects.all() + context["printer_form"] = PrinterSelectForm() + return context + + +class CardListView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, SingleTableView): """List view for all cards.""" permission_required = "core.view_cards_rule" @@ -75,7 +87,31 @@ class CardDeactivateView(PermissionRequiredMixin, RevisionMixin, SingleObjectMix return redirect(self.success_url) -class CardDetailView(PermissionRequiredMixin, RevisionMixin, DetailView): +class CardPrintView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View): + """View used to create a print job for 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() + + printer = self.request.GET.get("printer") + printer = get_object_or_404(CardPrinter, pk=printer) + + self.object.print_card(printer) + messages.success( + request, + _( + "The print job for the card {} on the printer {} has been created successfully." + ).format(self.object.person, printer.name), + ) + + return redirect(self.success_url) + + +class CardDetailView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, DetailView): permission_required = "core.view_card_rule" model = Card template_name = "kort/card/detail.html" @@ -110,3 +146,84 @@ class CardGeneratePDFView(PermissionRequiredMixin, RevisionMixin, SingleObjectMi button_url=redirect_url, button_icon="credit_card", ) + + +class CardPrinterListView(PermissionRequiredMixin, RevisionMixin, SingleTableView): + """List view for all card printers.""" + + permission_required = "core.view_cardprinters_rule" + template_name = "kort/printer/list.html" + model = CardPrinter + table_class = CardPrinterTable + + def get_queryset(self): + return CardPrinter.objects.all().annotate( + jobs_count=Count( + "jobs", filter=~Q(jobs__status__in=[PrintStatus.FINISHED, PrintStatus.FAILED]) + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + CardPrinter.check_online_status_for_all() + return context + + +class CardPrinterCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView): + """View used to create a card printer.""" + + permission_required = "core.create_cardprinter_rule" + template_name = "kort/printer/create.html" + form_class = CardPrinterForm + model = CardPrinter + success_message = _("The card printer has been created successfully.") + + def get_success_url(self): + return reverse("card_printer", args=[self.object.pk]) + + +class CardPrinterEditView(PermissionRequiredMixin, RevisionMixin, AdvancedEditView): + """View used to edit a card printer.""" + + permission_required = "core.edit_cardprinter_rule" + template_name = "kort/printer/edit.html" + form_class = CardPrinterForm + model = CardPrinter + success_message = _("The card printer has been changed successfully.") + + def get_success_url(self): + return reverse("card_printer", args=[self.object.pk]) + + +class CardPrinterDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): + """View used to delete a card printer.""" + + permission_required = "core.delete_cardprinter_rule" + success_url = reverse_lazy("card_printers") + template_name = "kort/printer/delete.html" + model = CardPrinter + success_message = _("The card printer has been deleted successfully.") + + +class CardPrinterDetailView(PermissionRequiredMixin, RevisionMixin, DetailView): + permission_required = "core.view_cardprinter_rule" + model = CardPrinter + template_name = "kort/printer/detail.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + self.object.check_online_status() + return context + + +class CardPrinterConfigView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View): + permission_required = "core.view_cardprinter_rule" + model = CardPrinter + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + response = HttpResponse( + json.dumps(self.object.generate_config()), content_type="application/json" + ) + response["Content-Disposition"] = f'attachment; filename="{self.object.config_filename}"' + return response