From 14df026559a1d3cbb30544b52198e50c40b0066f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= Date: Sat, 13 Jun 2026 20:58:10 +0200 Subject: [PATCH 1/2] generate coupon pdfs in the background --- poukazky/app/admin.py | 35 +++++++++++++++-------------------- poukazky/app/tasks.py | 32 ++++++++++++++++++++++++++++++-- poukazky/app/urls.py | 12 +++++++++++- poukazky/app/utils.py | 4 +++- poukazky/app/views.py | 27 ++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 25 deletions(-) diff --git a/poukazky/app/admin.py b/poukazky/app/admin.py index 9b7e4d8..1e8abbe 100644 --- a/poukazky/app/admin.py +++ b/poukazky/app/admin.py @@ -3,16 +3,19 @@ from django import forms from django.contrib import admin, messages from django.db import IntegrityError, transaction +from django.db.models import QuerySet from django.forms import ValidationError -from django.http import HttpResponse +from django.http import HttpResponseRedirect +from django.urls import reverse from django_admin_action_forms.admin import AdminActionFormsMixin from django_admin_action_forms.decorators import action_with_form from django_admin_action_forms.forms import AdminActionForm from django_no_queryset_admin_actions.admin import NoQuerySetAdminActionsMixin from django_no_queryset_admin_actions.decorators import no_queryset_action +from rq.job import Job from poukazky.app.models import ExternalCoupon, Provider, TrojstenCoupon -from poukazky.app.utils import generate_coupons +from poukazky.app.tasks import generate_coupons_pdf class GenerateCouponForm(AdminActionForm): @@ -59,30 +62,22 @@ def is_used(self, value): def generate_coupon(self, request, data): with transaction.atomic(): coupons = [ - TrojstenCoupon.generate(data["amount"], data["expires_at"]) + TrojstenCoupon.generate(data["amount"], data["expires_at"]).id for _ in range(data["count"]) ] - output = generate_coupons(coupons) - - return HttpResponse( - output, - content_type="application/pdf", - headers={ - "Content-Disposition": 'attachment; filename="trojsten-poukazky.pdf"' - }, + job: Job = generate_coupons_pdf.delay(coupons) # type:ignore + return HttpResponseRedirect( + reverse("coupon_pdf_download", kwargs={"job_id": job.id}) ) @admin.display(description="Vygenerovať PDF označených poukážok") - def regenerate_coupons(self, request, queryset): - output = generate_coupons(queryset.all()) - - return HttpResponse( - output, - content_type="application/pdf", - headers={ - "Content-Disposition": 'attachment; filename="trojsten-poukazky.pdf"' - }, + def regenerate_coupons(self, request, queryset: QuerySet[TrojstenCoupon]): + job: Job = generate_coupons_pdf.delay( + list(queryset.values_list("id", flat=True)) + ) # type:ignore + return HttpResponseRedirect( + reverse("coupon_pdf_download", kwargs={"job_id": job.id}) ) @action_with_form( diff --git a/poukazky/app/tasks.py b/poukazky/app/tasks.py index db85f90..6a21f7f 100644 --- a/poukazky/app/tasks.py +++ b/poukazky/app/tasks.py @@ -1,7 +1,13 @@ +import secrets +from datetime import timedelta +from io import BytesIO + +from django.core.files.storage import default_storage +from django.utils import timezone from django_rq import job -from poukazky.app.models import ExternalCoupon -from poukazky.app.utils import send_mail +from poukazky.app.models import ExternalCoupon, TrojstenCoupon +from poukazky.app.utils import generate_coupons, send_mail @job @@ -13,3 +19,25 @@ def coupon_exchanged(coupon_id: int): "coupon_exchanged", context={"coupon": coupon}, ) + + +@job +def generate_coupons_pdf(coupon_ids: list[int]) -> str: + coupons = TrojstenCoupon.objects.filter(id__in=coupon_ids) + pdf_bytes = generate_coupons(coupons) + + filename = f"tmp_coupons/{secrets.token_hex(8)}.pdf" + default_storage.save(filename, BytesIO(pdf_bytes)) + cleanup_coupon_pdfs.delay() + return filename + + +@job +def cleanup_coupon_pdfs(): + cutoff = timezone.now() - timedelta(minutes=30) + _, filenames = default_storage.listdir("tmp_coupons") + + for filename in filenames: + path = f"tmp_coupons/{filename}" + if default_storage.get_modified_time(path) < cutoff: + default_storage.delete(path) diff --git a/poukazky/app/urls.py b/poukazky/app/urls.py index 8981343..3056487 100644 --- a/poukazky/app/urls.py +++ b/poukazky/app/urls.py @@ -1,6 +1,11 @@ from django.urls import path -from poukazky.app.views import CouponDetailView, CouponExchangeView, CouponFormView +from poukazky.app.views import ( + CouponDetailView, + CouponExchangeView, + CouponFormView, + CouponPDFView, +) urlpatterns = [ path("", CouponFormView.as_view(), name="coupon_form"), @@ -10,4 +15,9 @@ CouponExchangeView.as_view(), name="coupon_exchange", ), + path( + "download_pdf//", + CouponPDFView.as_view(), + name="coupon_pdf_download", + ), ] diff --git a/poukazky/app/utils.py b/poukazky/app/utils.py index 026b370..3bca71e 100644 --- a/poukazky/app/utils.py +++ b/poukazky/app/utils.py @@ -1,3 +1,5 @@ +from typing import Iterable + from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string @@ -6,7 +8,7 @@ from poukazky.app.render_typst import render_typst -def generate_coupons(coupons: list[TrojstenCoupon]) -> bytes: +def generate_coupons(coupons: Iterable[TrojstenCoupon]) -> bytes: ctx = {} ctx["coupons"] = [ diff --git a/poukazky/app/views.py b/poukazky/app/views.py index b6234af..f448de9 100644 --- a/poukazky/app/views.py +++ b/poukazky/app/views.py @@ -1,9 +1,12 @@ from typing import Any +import django_rq from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.files.storage import default_storage from django.db import transaction from django.db.models import Exists, OuterRef -from django.http import HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone @@ -11,6 +14,8 @@ from django.utils.timezone import timedelta from django.views import View from django.views.generic import DetailView, FormView +from rq.exceptions import NoSuchJobError +from rq.job import Job, JobStatus from poukazky.app.forms import CouponExchangeForm, CouponSearchForm from poukazky.app.models import ExternalCoupon, Provider, TrojstenCoupon @@ -136,3 +141,23 @@ def form_valid(self, form): coupon_exchanged.delay(external_coupon.id) return redirect("coupon_detail", code=self.coupon.code) + + +class CouponPDFView(PermissionRequiredMixin, View): + permission_required = "app.create_trojstencoupon" + + def get(self, request, *args, **kwargs): + conn = django_rq.get_connection() + try: + job = Job.fetch(kwargs["job_id"], conn) + if job.func_name != "poukazky.app.tasks.generate_coupons_pdf": + raise Http404() + except NoSuchJobError: + raise Http404() + + if job.get_status() != JobStatus.FINISHED: + resp = HttpResponse("Rendering...") + resp.headers["Refresh"] = 5 + return resp + + return HttpResponseRedirect(default_storage.url(job.result)) From b8f6bc7951eedad677de698ca37a6296a98a0977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= Date: Tue, 14 Jul 2026 13:48:06 +0200 Subject: [PATCH 2/2] increase token length --- poukazky/app/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poukazky/app/tasks.py b/poukazky/app/tasks.py index 6a21f7f..f2cbbbc 100644 --- a/poukazky/app/tasks.py +++ b/poukazky/app/tasks.py @@ -26,7 +26,7 @@ def generate_coupons_pdf(coupon_ids: list[int]) -> str: coupons = TrojstenCoupon.objects.filter(id__in=coupon_ids) pdf_bytes = generate_coupons(coupons) - filename = f"tmp_coupons/{secrets.token_hex(8)}.pdf" + filename = f"tmp_coupons/{secrets.token_hex(32)}.pdf" default_storage.save(filename, BytesIO(pdf_bytes)) cleanup_coupon_pdfs.delay() return filename