diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index fb1b58be..04754d25 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -33,6 +33,16 @@ jobs: # Maps tcp port 5432 on service container to the host - 5432:5432 + gotenberg: + image: gotenberg/gotenberg:8 + options: >- + --health-cmd "curl --fail http://localhost:3000/health" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3000:3000 + steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/fm_eventmanager/settings.py.docker b/fm_eventmanager/settings.py.docker index b91a4123..aa7b8035 100644 --- a/fm_eventmanager/settings.py.docker +++ b/fm_eventmanager/settings.py.docker @@ -414,3 +414,6 @@ ENVIRONMENT_NAME = os.getenv('ENVIRONMENT_NAME', "Production Server") ENVIRONMENT_COLOR = os.getenv('ENVIRONMENT_COLOR', "#FF0000") ENVIRONMENT_TEXT_COLOR = os.getenv('ENVIRONMENT_TEXT_COLOR', "#00FF00") ENVIRONMENT_FLOAT = os.getenv('ENVIRONMENT_FLOAT', False) + +PRINT_RENDERER = os.getenv('PRINT_RENDERER', 'wkhtmltopdf') +GOTENBERG_HOST = os.getenv('GOTENBERG_HOST', None) diff --git a/fm_eventmanager/settings.py.travis b/fm_eventmanager/settings.py.travis index 0f7cfb10..c9e0767a 100644 --- a/fm_eventmanager/settings.py.travis +++ b/fm_eventmanager/settings.py.travis @@ -253,3 +253,5 @@ ENVIRONMENT_NAME = "Production Server" ENVIRONMENT_COLOR = "#FF0000" ENVIRONMENT_TEXT_COLOR = "#00FF00" ENVIRONMENT_FLOAT = True + +GOTENBERG_HOST = "http://localhost:3000" diff --git a/registration/admin.py b/registration/admin.py index ca113df7..63aa1bb9 100644 --- a/registration/admin.py +++ b/registration/admin.py @@ -11,8 +11,9 @@ from django.contrib import admin, auth, messages from django.contrib.auth.models import User from django.contrib.sites.models import Site +from django.core.signing import TimestampSigner from django.db import transaction -from django.db.models import JSONField, Max +from django.db.models import Max from django.forms import NumberInput, widgets from django.http import HttpResponseRedirect from django.shortcuts import render @@ -544,7 +545,7 @@ class EventAdmin(admin.ModelAdmin): "venue", "charity", "donations", - ("codeOfConduct", "badgeTheme"), + ("codeOfConduct", "badgeTheme", "defaultBadgeTemplate"), ) }, ), @@ -945,7 +946,17 @@ def get_attendee_age(attendee): def print_badges(modeladmin, request, queryset): - pdf_path = generate_badge_labels(queryset, request) + if getattr(settings, "PRINT_RENDERER", "wkhtmltopdf") == "gotenberg": + signer = TimestampSigner() + data = signer.sign_object({ + "badge_ids": [badge.id for badge in queryset], + }) + + pdf_path = reverse("registration:pdf") + f"?data={data}" + else: + pdf_name = generate_badge_labels(queryset, request) + pdf_path = reverse("registration:pdf") + f"?file={pdf_name}" + response = HttpResponseRedirect(reverse("registration:print")) url_params = {"file": pdf_path, "next": request.get_full_path()} @@ -1615,3 +1626,47 @@ def headers_highlighted(self, instance): admin.site.register(PaymentWebhookNotification, PaymentWebhookAdmin) + + +class BadgeTemplateAdmin(admin.ModelAdmin): + list_display = ( + "name", + "paperWidth", + "paperHeight", + "marginTop", + "marginBottom", + "marginLeft", + "marginRight", + "landscape", + "scale" + ) + + fieldsets = ( + ( + None, + { + "fields": ("name", "template"), + } + ), + ( + "Paper Setup", + { + "fields": ( + "landscape", + "scale", + ("paperWidth", "paperHeight"), + ) + } + ), + ( + "Margins And Padding", + { + "fields": ( + ("marginTop", "marginBottom"), + ("marginLeft", "marginRight"), + ), + } + ), + ) + +admin.site.register(BadgeTemplate, BadgeTemplateAdmin) diff --git a/registration/migrations/0103_auto_20241109_1543.py b/registration/migrations/0103_auto_20241109_1543.py new file mode 100644 index 00000000..cd258aa7 --- /dev/null +++ b/registration/migrations/0103_auto_20241109_1543.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.25 on 2024-11-09 20:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('registration', '0102_event_dealer_wifi_partner_price'), + ] + + operations = [ + migrations.CreateModel( + name='BadgeTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('template', models.TextField()), + ('paperWidth', models.CharField(max_length=10, null=True)), + ('paperHeight', models.CharField(max_length=10, null=True)), + ('marginTop', models.CharField(max_length=10, null=True)), + ('marginBottom', models.CharField(max_length=10, null=True)), + ('marginLeft', models.CharField(max_length=10, null=True)), + ('marginRight', models.CharField(max_length=10, null=True)), + ('landscape', models.BooleanField(default=True)), + ('scale', models.FloatField(default=1.0)), + ], + ), + migrations.AddField( + model_name='event', + name='defaultBadgeTemplate', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='registration.badgetemplate'), + ), + ] diff --git a/registration/models.py b/registration/models.py index 31e78eaa..99456722 100644 --- a/registration/models.py +++ b/registration/models.py @@ -162,6 +162,22 @@ def __str__(self): return self.name +class BadgeTemplate(models.Model): + name = models.CharField(max_length=100) + template = models.TextField() + paperWidth = models.CharField(max_length=10, null=True, verbose_name="Paper Width") + paperHeight = models.CharField(max_length=10, null=True, verbose_name="Paper Height") + marginTop = models.CharField(max_length=10, null=True, verbose_name = "Margin Top") + marginBottom = models.CharField(max_length=10, null=True, verbose_name = "Margin Bottom") + marginLeft = models.CharField(max_length=10, null=True, verbose_name="Margin Left") + marginRight = models.CharField(max_length=10, null=True, verbose_name = "Margin Right") + landscape = models.BooleanField(default=True) + scale = models.FloatField(default=1.0) + + def __str__(self): + return str(self.name) + + class Event(LookupTable): dealerRegStart = models.DateTimeField( verbose_name="Dealer Registration Start", @@ -195,6 +211,12 @@ class Event(LookupTable): blank=True, on_delete=models.SET_NULL, ) + defaultBadgeTemplate = models.ForeignKey( + BadgeTemplate, + null=True, + on_delete=models.SET_NULL, + verbose_name="Badge Template", + ) newStaffDiscount = models.ForeignKey( Discount, null=True, diff --git a/registration/templates/registration/printing.html b/registration/templates/registration/printing.html index e683857e..d5d1ae5a 100644 --- a/registration/templates/registration/printing.html +++ b/registration/templates/registration/printing.html @@ -7,7 +7,7 @@ Go Back " + ) + self.badge_template.save() + + self.event = Event( + defaultBadgeTemplate=self.badge_template, **DEFAULT_EVENT_ARGS + ) + self.event.save() + + self.priceLevel = PriceLevel( + name="Attendee", + description="Hello", + basePrice=1.00, + startDate=now - ten_days, + endDate=now + ten_days, + public=True, + ) + self.priceLevel.save() + + self.order = Order( + total=100, + billingType=Order.CREDIT, + status=Order.COMPLETED, + reference="CREDIT_ORDER_1", + ) + self.order.save() + + self.attendee = Attendee(**TEST_ATTENDEE_ARGS) + self.attendee.save() + + self.badge = Badge(event=self.event, attendee=self.attendee) + self.badge.save() + + self.order_item = OrderItem( + order=self.order, + badge=self.badge, + priceLevel=self.priceLevel, + ) + self.order_item.save() + + self.client = Client() + + def _badge_generates_pdf(self) -> dict: + self.assertTrue(self.client.login(username="admin", password="admin")) + + response = self.client.get( + reverse("registration:onsite_print_badges") + f"?id={self.badge.pk}" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsNotNone(data["file"]) + + self.client.logout() + + response = self.client.get(data["file"]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["content-type"], "application/pdf") + + return data + + def test_print_wkhtmltopdf(self): + settings.PRINT_RENDERER = "wkhtmltopdf" + data = self._badge_generates_pdf() + # wkhtmltopdf responses return a direct path to the file + self.assertIn("?file=", data["file"]) + + def test_print_gotenberg(self): + settings.PRINT_RENDERER = "gotenberg" + data = self._badge_generates_pdf() + # gotenberg responses return a signed data parameter with badge IDs + self.assertIn("?data=", data["file"]) diff --git a/registration/views/onsite_admin.py b/registration/views/onsite_admin.py index b3677890..972d5ac2 100644 --- a/registration/views/onsite_admin.py +++ b/registration/views/onsite_admin.py @@ -10,15 +10,17 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import permission_required from django.contrib.messages import get_messages +from django.core.signing import TimestampSigner from django.db.models import Q, Sum from django.http import JsonResponse from django.shortcuts import redirect, render from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone +from django.utils.http import urlencode from django.views.decorators.csrf import csrf_exempt -from registration import admin, mqtt, payments, printing +from registration import admin, mqtt, payments from registration.admin import TWOPLACES from registration.models import ( Badge, @@ -342,20 +344,35 @@ def get_messages_list(request): @staff_member_required def onsite_print_badges(request): badge_list = request.GET.getlist("id") - queryset = Badge.objects.filter(id__in=badge_list) - pdf_path = admin.generate_badge_labels(queryset, request) - # Async notify the frontend to refresh the cart - logger.info("Refreshing admin cart") - admin_push_cart_refresh(request) - file_url = reverse("registration:print") + "?file={0}".format(pdf_path) + if getattr(settings, "PRINT_RENDERER", "wkhtmltopdf") == "gotenberg": + terminal = get_active_terminal(request) + + signer = TimestampSigner() + data = signer.sign_object({ + "badge_ids": [int(badge_id) for badge_id in badge_list], + "terminal": terminal.name if terminal else None, + }) + + pdf_path = reverse("registration:pdf") + f"?data={data}" + else: + queryset = Badge.objects.filter(id__in=badge_list) + pdf_name = admin.generate_badge_labels(queryset, request) + + pdf_path = reverse("registration:pdf") + f"?file={pdf_name}" + + # Async notify the frontend to refresh the cart + logger.info("Refreshing admin cart") + admin_push_cart_refresh(request) + + print_url = reverse("registration:print") + "?" + urlencode({"file": pdf_path}) return JsonResponse( { "success": True, - "file": pdf_path, "next": request.get_full_path(), - "url": file_url, + "file": pdf_path, + "url": print_url, } ) diff --git a/registration/views/printing.py b/registration/views/printing.py index fb27b7f9..553de355 100644 --- a/registration/views/printing.py +++ b/registration/views/printing.py @@ -1,8 +1,23 @@ import logging -import os +from pathlib import Path +from typing import Union -from django.http import FileResponse, JsonResponse +from django.conf import settings +from django.contrib import messages +from django.core.signing import TimestampSigner +from django.http import FileResponse, HttpRequest, HttpResponse, JsonResponse from django.shortcuts import render +from django.template import Context, Template +from gotenberg_client import GotenbergClient +from gotenberg_client.options import ( + MarginType, + PageMarginsType, + PageOrientation, + PageSize, +) + +from registration import mqtt +from registration.models import Badge, Dealer, Staff logger = logging.getLogger(__name__) @@ -16,16 +31,139 @@ def printNametag(request): def servePDF(request): - filename = request.GET.get("file", None) - if filename is None: - return JsonResponse({"success": False, "reason": "Bad request"}, status=400) - filename = filename.replace("..", "/") + if getattr(settings, "PRINT_RENDERER", "wkhtmltopdf") == "gotenberg": + return pdfFromGotenberg(request) + else: + return pdfFromDisk(request) + + +def pdfFromDisk(request: HttpRequest) -> Union[FileResponse, JsonResponse]: + name = request.GET.get("file", None) + if not name or not isinstance(name, str): + return JsonResponse( + {"success": False, "reason": "Name was missing"}, status=400 + ) + + root_dir = getattr(settings, "PDF_DIRECTORY", "/tmp") try: - fsock = open(f"/tmp/{filename}", "rb") - except IOError as e: - return JsonResponse({"success": False, "reason": "file not found"}, status=404) - response = FileResponse(fsock, content_type="application/pdf") - # response['Content-Disposition'] = 'attachment; filename=download.pdf' + path = Path(root_dir).joinpath(Path(name).name) + f = open(path, "rb") + except IOError: + return JsonResponse({"success": False, "reason": "IO error"}, status=404) + + response = FileResponse(f, content_type="application/pdf") response["Access-Control-Allow-Origin"] = "*" - # os.unlink("/tmp/%s" % filename) return response + + +def pdfFromGotenberg(request: HttpRequest) -> Union[HttpResponse, JsonResponse]: + data = request.GET.get("data", None) + if not data: + return JsonResponse({"success": False, "reason": "Missing data"}, status=400) + + signer = TimestampSigner() + try: + data_obj = signer.unsign_object(data, max_age=60) + except: + return JsonResponse({"success": False, "reason": "Invalid data"}, status=401) + + badge_ids = data_obj.get("badge_ids", []) + queryset = Badge.objects.filter(id__in=badge_ids) + + badge_groups = {} + badge_templates = {} + + for badge in queryset: + level = badge.effectiveLevel() + if not level or level == Badge.UNPAID: + messages.warning( + request, f"skipped printing {badge} because level is {level}" + ) + continue + + badge_template = badge.event.defaultBadgeTemplate + if badge_template.id not in badge_templates: + badge_groups[badge_template.id] = [] + badge_templates[badge_template.id] = ( + badge_template, + Template(badge_template.template), + ) + + level = str(level) + if Staff.objects.filter(attendee=badge.attendee, event=badge.event).exists(): + level = "Staff" + elif Dealer.objects.filter(attendee=badge.attendee, event=badge.event).exists(): + level = "Dealer" + + badge_groups[badge_template.id].append( + { + "name": badge.badgeName, + "level": level, + "number": badge.badgeNumber, + } + ) + + with GotenbergClient(settings.GOTENBERG_HOST) as client: + pdfs = [] + + for badge_template_id, badges in badge_groups.items(): + (badge_template, template) = badge_templates[badge_template_id] + context = Context({"badges": badges}) + rendered = str(template.render(context)) + + with client.chromium.html_to_pdf() as route: + response = ( + route.size( + PageSize(badge_template.paperWidth, badge_template.paperHeight) + ) + .margins( + PageMarginsType( + MarginType(badge_template.marginTop), + MarginType(badge_template.marginBottom), + MarginType(badge_template.marginLeft), + MarginType(badge_template.marginRight), + ) + ) + .orient( + PageOrientation( + PageOrientation.Landscape + if badge_template.landscape + else PageOrientation.Portrait + ) + ) + .scale(badge_template.scale) + .string_resource(rendered, "index.html") + .render_expr("window.badgeReady === true") + .run() + ) + pdfs.append(response.content) + + finalPdf = None + + if len(pdfs) == 0: + return JsonResponse( + {"success": False, "reason": "No PDFs were generated"}, status=404 + ) + elif len(pdfs) == 1: + finalPdf = pdfs[0] + else: + with client.merge.merge() as route: + req = route + for index, badgePdf in enumerate(pdfs): + req._add_in_memory_file(badgePdf, name=f"{index}.pdf") + response = req.run() + finalPdf = response.content + + http_resp = HttpResponse() + http_resp.headers["content-type"] = "application/pdf" + http_resp.write(finalPdf) + + for badge in queryset: + badge.printed = True + badge.save() + + if terminal := data_obj.get("terminal", None): + topic = f"{mqtt.get_topic('admin', terminal)}/refresh" + mqtt.send_mqtt_message(topic, None) + + return http_resp diff --git a/requirements.txt b/requirements.txt index 0fb7494e..ac7bb4be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ django-redis~=5.4.0 git+https://github.com/rechner/django-u2f.git@23022f8 django-widget-tweaks==1.5.0 freezegun==1.4.0 +gotenberg-client~=0.7.0 influxdb~=5.3.1 # Influx < v1.7 influxdb-client~=1.40.0 # Influxdb 1.8, 2.x markuppy