From a2b52e4264e8f93afaafc4b118bdf82d82495d0a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 00:29:18 +0300 Subject: [PATCH 1/4] feat: add program certificate issuance and delivery API --- .github/workflows/backend-postgres-ci.yml | 2 + Dockerfile | 4 +- docs/modules/program-certificates.md | 57 +++ partner_programs/certificate_models.py | 72 +++ partner_programs/certificate_views.py | 307 +++++++++++ ...asset_programcertificateconfig_and_more.py | 79 +++ partner_programs/models.py | 6 + partner_programs/services/certificates.py | 477 ++++++++++++++++++ partner_programs/tasks.py | 52 ++ partner_programs/tests/test_certificates.py | 333 ++++++++++++ partner_programs/urls.py | 42 ++ procollab/celery.py | 4 + 12 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 docs/modules/program-certificates.md create mode 100644 partner_programs/certificate_models.py create mode 100644 partner_programs/certificate_views.py create mode 100644 partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py create mode 100644 partner_programs/services/certificates.py create mode 100644 partner_programs/tests/test_certificates.py diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index d07e6269..cc828a37 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -59,6 +59,8 @@ jobs: - name: Install dependencies run: | + sudo apt-get update + sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 fontconfig python -m pip install "poetry==1.2.2" poetry config virtualenvs.create true poetry install --no-interaction --no-ansi --no-root diff --git a/Dockerfile b/Dockerfile index 282824c5..8d65b226 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM python:3.11 -RUN apt update --no-install-recommends -y +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 fontconfig libjpeg62-turbo libopenjp2-7 \ + && rm -rf /var/lib/apt/lists/* ENV PYTHONFAULTHANDLER=1 \ PYTHONUNBUFFERED=1 \ diff --git a/docs/modules/program-certificates.md b/docs/modules/program-certificates.md new file mode 100644 index 00000000..141ae3f5 --- /dev/null +++ b/docs/modules/program-certificates.md @@ -0,0 +1,57 @@ +# Автоматические сертификаты + +Утвержденный макет: Figma `tvog0fpEgWgEtr7KUTjbyE`, страница `904:3219`. +Экраны: шаблон `904:3220`, перетаскивание `910:3756` / `916:4155`, +автовыдача `904:3221`, журнал ошибок `940:4465`. + +Доступ к управлению: существующий `managed_program` (менеджер программы / staff). +Получатели: уникальные зарегистрированные пользователи; для режима `submitted` +только руководители и участники команд с `PartnerProgramProject.submitted=True` +в этой программе. Анкета `profile.project` не подменяет членство в команде. +Подтверждение почты не является условием выдачи. + +## API + +Все управляющие пути: `/programs/{id}/management/certificates/`, private/no-store. +JSON использует snake_case; штатный Angular API interceptor преобразует регистр. + +- GET /: `{revision,draft,assets,eligible_counts,batch}`. Начальный шаблон без демоданных. +- PUT /: `{revision,draft}`. Конфликт ревизии 409, невалидные поля 400. +- POST assets/: multipart `file`, `kind=background|signature`; проверка реального + содержимого PNG/JPEG, до 10 MB и 20 MP; подпись PNG. GET assets/{asset_id}/. +- POST preview/: `{revision}` → PDF с именем текущего организатора, без выдачи. +- POST test/: `{revision}` → письмо с пробным PDF только текущему организатору. +- POST activate/: `{revision}` → неизменяемый снимок шаблона и рассылки; + `timing=manual` запускает сейчас, `scheduled` требует дату не ранее окончания. +- POST pause/ и resume/: останавливают/возобновляют очередь текущего запуска. +- GET deliveries/: `status=all|sent|failed`, `search`, `limit` (до100), `offset`; + `{count,results,summary}`; PDF и отправка имеют независимые состояния. +- POST retry/: `{ids?: UUID[]}`; только ошибки, повторно используется готовый PDF. +- GET deliveries/{certificate_id}/pdf/: PDF, GET archive/: ZIP готовых PDF + с теми же фильтрами. Ошибки без готового PDF остаются в журнале. +- GET `/programs/{id}/certificates/me/`: собственные выданные сертификаты. +- GET `/programs/{id}/certificates/download/{token}/`: PDF по секретной ссылке + из письма (случайный UUID, без раскрытия списка участников). + +Celery beat раз в минуту подбирает запуски и сохраняемую очередь. Уникальный +ключ program/user исключает повторную выдачу. Старые документы неизменяемы. +Отправка с неопределенным результатом после падения worker требует ручного +повтора, чтобы автоматически не дублировать письма. `sent` означает принятие +почтовым провайдером, а не доказательство попадания во входящие. + +Активы и PDF хранятся приватно в БД: не требуют нового публичного CDN или +непостоянного диска контейнера, сохраняют прозрачность подписи. Это увеличивает +объем БД; для больших объемов можно заменить содержимое приватным object storage. +PDF использует уже имеющиеся WeasyPrint и Mont. Загрузчик PDF запрещает сеть +и произвольные файлы. Поля письма поддерживают только перечисленные подстановки. + +## Приемка + +1. Новая вкладка доступна менеджеру, чужая программа возвращает 403. +2. Фон/подпись загружаются, элементы перетаскиваются/масштабируются без X/Y-полей. +3. PDF A4 альбомный, кириллица и длинные имена корректны, есть пробный PDF. +4. Два режима получателей, дедупликация регистраций и команд. +5. Выдача по расписанию или вручную, подтверждение и пауза очереди. +6. Фильтры Все/Отправлено/Ошибки с серверным поиском и пагинацией. +7. Повтор только ошибок, тот же PDF, отправленные письма не дублируются. +8. Свой PDF в программе и ссылка в сервисном письме; защита чужих документов. diff --git a/partner_programs/certificate_models.py b/partner_programs/certificate_models.py new file mode 100644 index 00000000..af9fbe89 --- /dev/null +++ b/partner_programs/certificate_models.py @@ -0,0 +1,72 @@ +"""Private certificate assets, immutable issuance snapshots and delivery journal.""" + +import uuid + +from django.conf import settings +from django.db import models + + +class ProgramCertificateAsset(models.Model): + program = models.ForeignKey("PartnerProgram", on_delete=models.CASCADE) + kind = models.CharField(max_length=12) + name = models.CharField(max_length=255) + content = models.BinaryField() + width = models.PositiveIntegerField() + height = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + + +class ProgramCertificateConfig(models.Model): + program = models.OneToOneField( + "PartnerProgram", on_delete=models.CASCADE, related_name="certificate_config" + ) + revision = models.PositiveIntegerField(default=1) + draft = models.JSONField(default=dict) + last_test_at = models.DateTimeField(null=True) + + +class ProgramCertificateBatch(models.Model): + program = models.ForeignKey("PartnerProgram", on_delete=models.CASCADE) + snapshot = models.JSONField() + background = models.ForeignKey( + ProgramCertificateAsset, + null=True, + on_delete=models.PROTECT, + related_name="background_batches", + ) + signature = models.ForeignKey( + ProgramCertificateAsset, + null=True, + on_delete=models.PROTECT, + related_name="signature_batches", + ) + active = models.BooleanField(default=True) + issue_at = models.DateTimeField(db_index=True) + initialized_at = models.DateTimeField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + public_base_url = models.URLField() + + +class ProgramCertificate(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + download_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + program = models.ForeignKey("PartnerProgram", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + batch = models.ForeignKey(ProgramCertificateBatch, on_delete=models.PROTECT) + name = models.CharField(max_length=500) + email = models.EmailField() + pdf = models.BinaryField(null=True) + status = models.CharField(max_length=16, default="pending", db_index=True) + error = models.CharField(max_length=300, blank=True) + attempts = models.PositiveIntegerField(default=0) + queued_at = models.DateTimeField(null=True) + claimed_at = models.DateTimeField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + sent_at = models.DateTimeField(null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["program", "user"], name="unique_program_certificate" + ) + ] diff --git a/partner_programs/certificate_views.py b/partner_programs/certificate_views.py new file mode 100644 index 00000000..8e96a78e --- /dev/null +++ b/partner_programs/certificate_views.py @@ -0,0 +1,307 @@ +import zipfile +from datetime import timedelta + +from django.db import transaction +from django.db.models import Count, Q +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from rest_framework import serializers +from rest_framework.exceptions import APIException, ValidationError +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.certificate_models import ( + ProgramCertificate, + ProgramCertificateAsset, + ProgramCertificateBatch, + ProgramCertificateConfig, +) +from partner_programs.management_views import ManagementBase +from partner_programs.services.management import managed_program, person_name +from partner_programs.services import certificates as service + + +class Conflict(APIException): + status_code = 409 + default_detail = "Настройки изменены другим организатором. Обновите страницу." + + +def revision_check(config, data): + revision = serializers.IntegerField(min_value=1).run_validation(data.get("revision")) + if revision != config.revision: + raise Conflict() + + +def pdf_response(content, filename): + response = HttpResponse(bytes(content), content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}.pdf"' + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + return response + + +class CertificateSettingsView(ManagementBase): + def get(self, request, pk): + return Response(service.state(managed_program(pk, request.user))) + + @transaction.atomic + def put(self, request, pk): + program = managed_program(pk, request.user, lock=True) + service.config_for(program) + config = ProgramCertificateConfig.objects.select_for_update().get(program=program) + revision_check(config, request.data) + config.draft = service.validate_draft(program, request.data.get("draft")) + config.revision += 1 + config.save(update_fields=["revision", "draft"]) + return Response(service.state(program)) + + +class CertificateAssetView(ManagementBase): + def post(self, request, pk): + program = managed_program(pk, request.user) + asset = service.upload_asset( + program, request.data.get("kind"), request.FILES.get("file") + ) + return Response(service.asset_data(asset), status=201) + + def get(self, request, pk, asset_id): + program = managed_program(pk, request.user) + asset = get_object_or_404(ProgramCertificateAsset, pk=asset_id, program=program) + response = HttpResponse(bytes(asset.content), content_type="image/png") + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + return response + + +class CertificatePreviewView(ManagementBase): + def post(self, request, pk): + program = managed_program(pk, request.user) + config = service.config_for(program) + revision_check(config, request.data) + content = service.render_pdf( + config.draft, + service.assets_for(program, config.draft), + person_name(request.user), + program.name, + "ПРОБНЫЙ", + timezone.localtime(), + ) + return pdf_response(content, "certificate-preview") + + +class CertificateTestView(ManagementBase): + def post(self, request, pk): + program = managed_program(pk, request.user) + with transaction.atomic(): + service.config_for(program) + config = ProgramCertificateConfig.objects.select_for_update().get( + program=program + ) + revision_check(config, request.data) + if config.last_test_at and config.last_test_at > timezone.now() - timedelta( + minutes=1 + ): + raise ValidationError("Пробное письмо можно отправлять раз в минуту.") + config.last_test_at = timezone.now() + config.save(update_fields=["last_test_at"]) + from partner_programs.tasks import send_test_program_certificate + + send_test_program_certificate.delay(pk, request.user.pk, config.draft) + return Response( + { + "detail": "Пробный сертификат поставлен в очередь. Письмо придет на вашу почту." + }, + status=202, + ) + + +class CertificateActivateView(ManagementBase): + @transaction.atomic + def post(self, request, pk): + program = managed_program(pk, request.user, lock=True) + config = service.config_for(program) + revision_check(config, request.data) + draft = service.validate_draft(program, config.draft) + if ProgramCertificateBatch.objects.filter(program=program, active=True).exists(): + raise ValidationError("Сначала приостановите текущую выдачу.") + if ProgramCertificate.objects.filter( + program=program, status__in=["pending", "generating", "sending", "failed"] + ).exists(): + raise ValidationError( + "Сначала завершите предыдущую выдачу: возобновите очередь и повторите ошибки." + ) + issue_at = timezone.now() + if draft["timing"] == "scheduled": + issue_at = parse_datetime(draft["scheduled_at"] or "") + if ( + not issue_at + or issue_at <= timezone.now() + or issue_at < program.datetime_finished + ): + raise ValidationError( + "Укажите будущую дату не ранее окончания программы." + ) + if not service.recipient_users(program, draft["audience"]).exists(): + raise ValidationError("Нет участников, подходящих под выбранное условие.") + assets = service.assets_for(program, draft) + # Freeze program text as well as layout; later program renaming must not alter PDFs. + draft["program_name"] = program.name + ProgramCertificateBatch.objects.create( + program=program, + snapshot=draft, + background=assets["background"], + signature=assets["signature"], + issue_at=issue_at, + public_base_url=request.build_absolute_uri("/").rstrip("/"), + ) + return Response(service.state(program), status=201) + + +class CertificateQueueView(ManagementBase): + @transaction.atomic + def post(self, request, pk, action): + if action not in ("pause", "resume"): + raise ValidationError("Неизвестное действие с очередью.") + program = managed_program(pk, request.user, lock=True) + batch = ( + ProgramCertificateBatch.objects.select_for_update() + .filter(program=program) + .order_by("-pk") + .first() + ) + if not batch: + raise ValidationError("Выдача еще не запущена.") + batch.active = action == "resume" + batch.save(update_fields=["active"]) + return Response(service.state(program)) + + +class JournalQuery(serializers.Serializer): + status = serializers.ChoiceField(choices=["all", "sent", "failed"], default="all") + search = serializers.CharField(max_length=200, allow_blank=True, default="") + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + + +def journal_rows(program, params): + query = JournalQuery(data=params) + query.is_valid(raise_exception=True) + data = query.validated_data + rows = ProgramCertificate.objects.filter(program=program).defer("pdf") + if data["status"] != "all": + rows = rows.filter(status=data["status"]) + if data["search"]: + rows = rows.filter( + Q(name__icontains=data["search"]) | Q(email__icontains=data["search"]) + ) + return rows.order_by("created_at", "pk"), data + + +class CertificateJournalView(ManagementBase): + def get(self, request, pk): + program = managed_program(pk, request.user) + rows, query = journal_rows(program, request.query_params) + summary = ProgramCertificate.objects.filter(program=program).aggregate( + total=Count("pk"), + ready=Count("pk", filter=Q(pdf__isnull=False)), + sent=Count("pk", filter=Q(status="sent")), + failed=Count("pk", filter=Q(status="failed")), + ) + result = [ + { + "id": row.pk, + "name": row.name, + "email": row.email, + "status": row.status, + "pdf_ready": row.pdf_ready, + "error": row.error, + "sent_at": row.sent_at, + } + for row in rows.annotate(pdf_ready=~Q(pdf__isnull=True))[ + query["offset"] : query["offset"] + query["limit"] + ] + ] + return Response({"count": rows.count(), "summary": summary, "results": result}) + + +class CertificateRetryView(ManagementBase): + @transaction.atomic + def post(self, request, pk): + program = managed_program(pk, request.user, lock=True) + rows = ProgramCertificate.objects.filter(program=program, status="failed") + ids = request.data.get("ids") + if ids is not None: + serializer = serializers.ListField( + child=serializers.UUIDField(), min_length=1, max_length=100 + ) + rows = rows.filter(pk__in=serializer.run_validation(ids)) + # Retry requires an active batch. Pause remains authoritative for every worker. + if rows.filter(batch__active=False).exists(): + raise ValidationError("Возобновите выдачу перед повторной отправкой.") + count = rows.update(status="pending", error="", queued_at=None, claimed_at=None) + return Response({"count": count}, status=202) + + +class CertificateDownloadView(ManagementBase): + def get(self, request, pk, certificate_id): + program = managed_program(pk, request.user) + row = get_object_or_404( + ProgramCertificate, program=program, pk=certificate_id, pdf__isnull=False + ) + return pdf_response(row.pdf, "certificate-" + str(row.pk)[:8]) + + +class CertificateArchiveView(ManagementBase): + def get(self, request, pk): + program = managed_program(pk, request.user) + rows, _ = journal_rows(program, request.query_params) + # Spool to disk after 8 MB so a large program does not duplicate every PDF in RAM. + import tempfile + from django.http import FileResponse + + output = tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for row in ( + rows.filter(pdf__isnull=False).only("pk", "pdf").iterator(chunk_size=50) + ): + archive.writestr(f"certificate-{row.pk}.pdf", bytes(row.pdf)) + output.seek(0) + response = FileResponse(output, as_attachment=True, filename="certificates.zip") + response["Cache-Control"] = "private, no-store" + return response + + +class MyCertificatesView(ManagementBase): + def get(self, request, pk): + rows = ProgramCertificate.objects.filter( + program_id=pk, user=request.user, pdf__isnull=False + ) + return Response( + [ + { + "id": row.pk, + "issued_at": row.created_at, + "url": request.build_absolute_uri( + f"/programs/{pk}/certificates/download/{row.download_token}/" + ), + } + for row in rows.defer("pdf") + ] + ) + + +class PublicCertificateDownloadView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + + def get(self, request, pk, token): + row = get_object_or_404( + ProgramCertificate, program_id=pk, download_token=token, pdf__isnull=False + ) + response = pdf_response(row.pdf, "certificate-" + str(row.pk)[:8]) + response["Referrer-Policy"] = "no-referrer" + response["X-Robots-Tag"] = "noindex, nofollow" + return response diff --git a/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py b/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py new file mode 100644 index 00000000..185fd620 --- /dev/null +++ b/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.11 on 2026-10-23 21:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('partner_programs', '0028_programattentionreminderbatch_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProgramCertificateAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(max_length=12)), + ('name', models.CharField(max_length=255)), + ('content', models.BinaryField()), + ('width', models.PositiveIntegerField()), + ('height', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')), + ], + ), + migrations.CreateModel( + name='ProgramCertificateConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('revision', models.PositiveIntegerField(default=1)), + ('draft', models.JSONField(default=dict)), + ('last_test_at', models.DateTimeField(null=True)), + ('program', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate_config', to='partner_programs.partnerprogram')), + ], + ), + migrations.CreateModel( + name='ProgramCertificateBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('snapshot', models.JSONField()), + ('active', models.BooleanField(default=True)), + ('issue_at', models.DateTimeField(db_index=True)), + ('initialized_at', models.DateTimeField(null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('public_base_url', models.URLField()), + ('background', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='background_batches', to='partner_programs.programcertificateasset')), + ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')), + ('signature', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='signature_batches', to='partner_programs.programcertificateasset')), + ], + ), + migrations.CreateModel( + name='ProgramCertificate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('download_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=500)), + ('email', models.EmailField(max_length=254)), + ('pdf', models.BinaryField(null=True)), + ('status', models.CharField(db_index=True, default='pending', max_length=16)), + ('error', models.CharField(blank=True, max_length=300)), + ('attempts', models.PositiveIntegerField(default=0)), + ('queued_at', models.DateTimeField(null=True)), + ('claimed_at', models.DateTimeField(null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('sent_at', models.DateTimeField(null=True)), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='partner_programs.programcertificatebatch')), + ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='programcertificate', + constraint=models.UniqueConstraint(fields=('program', 'user'), name='unique_program_certificate'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 1c451ed9..e06c175c 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -4,6 +4,12 @@ from django.utils import timezone from files.models import UserFile +from partner_programs.certificate_models import ( # noqa: F401 + ProgramCertificate, + ProgramCertificateAsset, + ProgramCertificateBatch, + ProgramCertificateConfig, +) from partner_programs.constants import get_default_data_schema from projects.models import Project from partner_programs.attention_models import ( # noqa: F401 diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py new file mode 100644 index 00000000..bc3e1e55 --- /dev/null +++ b/partner_programs/services/certificates.py @@ -0,0 +1,477 @@ +"""Durable certificate issuance. No network resources are loaded while rendering.""" + +import base64 +import io +import logging +from datetime import timedelta +from html import escape +from pathlib import Path + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone +from PIL import Image, UnidentifiedImageError +from rest_framework.exceptions import ValidationError + +from partner_programs.certificate_models import ( + ProgramCertificate, + ProgramCertificateAsset, + ProgramCertificateBatch, + ProgramCertificateConfig, +) +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.management import person_name +from projects.models import Collaborator + +logger = logging.getLogger(__name__) +ELEMENTS = ( + "title", + "subtitle", + "name", + "program", + "date", + "number", + "director", + "position", + "signature", +) + + +def default_draft(): + positions = [ + (10, 12, 80, 7, 40), + (10, 23, 80, 5, 18), + (10, 35, 80, 10, 36), + (10, 50, 80, 10, 20), + (10, 78, 30, 5, 14), + (10, 84, 35, 5, 12), + (57, 79, 33, 5, 18), + (57, 85, 33, 5, 12), + (60, 65, 25, 12, 16), + ] + return { + "background_id": None, + "signature_id": None, + "director": "", + "position": "", + "show_text": True, + "audience": "submitted", + "timing": "manual", + "scheduled_at": None, + "send_email": True, + "subject": "Ваш сертификат за участие в программе {program}", + "message": "Здравствуйте, {name}!\nСпасибо за участие в программе «{program}».\nВаш сертификат готов.", + "layout": { + key: dict(zip(("x", "y", "width", "height", "font_size"), pos)) + for key, pos in zip(ELEMENTS, positions) + }, + } + + +def recipients(program, audience): + profiles = PartnerProgramUserProfile.objects.filter( + partner_program=program, user__isnull=False + ) + if audience == "submitted": + leaders = PartnerProgramProject.objects.filter( + partner_program=program, + submitted=True, + project__leader_id=OuterRef("user_id"), + ) + members = Collaborator.objects.filter( + user_id=OuterRef("user_id"), + project__program_links__partner_program=program, + project__program_links__submitted=True, + ) + profiles = profiles.annotate( + leader=Exists(leaders), member=Exists(members) + ).filter(Q(leader=True) | Q(member=True)) + return profiles + + +def recipient_users(program, audience): + from django.contrib.auth import get_user_model + + return ( + get_user_model() + .objects.filter(pk__in=recipients(program, audience).values("user_id")) + .order_by("pk") + ) + + +def config_for(program): + return ProgramCertificateConfig.objects.get_or_create( + program=program, defaults={"draft": default_draft()} + )[0] + + +def asset_data(asset): + return ( + {"id": asset.pk, "name": asset.name, "width": asset.width, "height": asset.height} + if asset + else None + ) + + +def assets_for(program, draft): + result = {} + for kind in ("background", "signature"): + pk = draft.get(kind + "_id") + asset = ( + ProgramCertificateAsset.objects.filter( + pk=pk, program=program, kind=kind + ).first() + if pk + else None + ) + if pk and not asset: + raise ValidationError({kind + "_id": "Файл не найден в этой программе."}) + result[kind] = asset + return result + + +def state(program): + config = config_for(program) + batch = ( + ProgramCertificateBatch.objects.filter(program=program).order_by("-pk").first() + ) + return { + "revision": config.revision, + "draft": config.draft, + "assets": { + key: asset_data(value) + for key, value in assets_for(program, config.draft).items() + }, + "eligible_counts": { + a: recipient_users(program, a).count() for a in ("all", "submitted") + }, + "batch": { + "id": batch.pk, + "active": batch.active, + "issue_at": batch.issue_at, + "initialized_at": batch.initialized_at, + } + if batch + else None, + } + + +def upload_asset(program, kind, upload): + if kind not in ("background", "signature") or not upload: + raise ValidationError("Выберите фон или подпись и файл.") + if upload.size > 10 * 1024 * 1024: + raise ValidationError("Размер файла не должен превышать 10 МБ.") + try: + image = Image.open(upload) + if image.format not in (("PNG",) if kind == "signature" else ("PNG", "JPEG")): + raise ValueError() + if image.width * image.height > 20_000_000 or min(image.size) < 10: + raise ValueError() + image.load() + image = image.convert("RGBA") + # Normalize metadata and orientation; never pass uploaded container data to PDF. + from PIL import ImageOps + + image = ImageOps.exif_transpose(image) + image.thumbnail((3508, 2480) if kind == "background" else (2000, 2000)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + if buffer.tell() > 20 * 1024 * 1024: + raise ValueError() + except ( + UnidentifiedImageError, + OSError, + ValueError, + Image.DecompressionBombError, + ) as error: + raise ValidationError( + "Нужен PNG или JPG до 20 мегапикселей; для подписи — PNG." + ) from error + return ProgramCertificateAsset.objects.create( + program=program, + kind=kind, + name=Path(upload.name).name[:255], + content=buffer.getvalue(), + width=image.width, + height=image.height, + ) + + +def validate_draft(program, draft): + """The API owns allowed geometry and substitutions, not arbitrary HTML/CSS.""" + from rest_framework import serializers + + class Element(serializers.Serializer): + x = serializers.FloatField(min_value=0, max_value=100) + y = serializers.FloatField(min_value=0, max_value=100) + width = serializers.FloatField(min_value=2, max_value=100) + height = serializers.FloatField(min_value=1, max_value=100) + font_size = serializers.FloatField(min_value=8, max_value=72) + + class Draft(serializers.Serializer): + background_id = serializers.IntegerField(allow_null=True) + signature_id = serializers.IntegerField(allow_null=True) + director = serializers.CharField(max_length=250, allow_blank=True) + position = serializers.CharField(max_length=250, allow_blank=True) + show_text = serializers.BooleanField() + audience = serializers.ChoiceField(choices=["all", "submitted"]) + timing = serializers.ChoiceField(choices=["manual", "scheduled"]) + scheduled_at = serializers.DateTimeField(allow_null=True) + send_email = serializers.BooleanField() + subject = serializers.CharField(max_length=200) + message = serializers.CharField(max_length=5000) + layout = serializers.DictField(child=Element()) + + serializer = Draft(data=draft) + serializer.is_valid(raise_exception=True) + data = serializer.data + if set(data["layout"]) != set(ELEMENTS): + raise ValidationError("Состав элементов шаблона изменен. Обновите страницу.") + for element in data["layout"].values(): + if ( + element["x"] + element["width"] > 100.01 + or element["y"] + element["height"] > 100.01 + ): + raise ValidationError("Элементы должны находиться внутри сертификата.") + if "\n" in data["subject"] or "\r" in data["subject"]: + raise ValidationError("Тема письма должна занимать одну строку.") + for field in ("subject", "message"): + import re + + if any( + token not in ("name", "program", "date", "number") + for token in re.findall(r"\{([^{}]+)\}", data[field]) + ): + raise ValidationError( + "Доступные подстановки: {name}, {program}, {date}, {number}." + ) + assets_for(program, data) + return dict(data) + + +def render_pdf(snapshot, assets, name, program_name, number, issued_at): + from weasyprint import HTML + from weasyprint.text.fonts import FontConfiguration + + fonts = Path(settings.BASE_DIR) / "assets/users/user_CV" + + def fetch(url): + if url == "certificate-font:regular": + return { + "string": (fonts / "Mont-Regular.ttf").read_bytes(), + "mime_type": "font/ttf", + } + if url == "certificate-font:bold": + return { + "string": (fonts / "Mont-SemiBold.ttf").read_bytes(), + "mime_type": "font/ttf", + } + if url.startswith("data:image/png;base64,"): + return { + "string": base64.b64decode(url.split(",", 1)[1], validate=True), + "mime_type": "image/png", + } + raise ValueError("External PDF resources are forbidden") + + def src(asset): + return "data:image/png;base64," + base64.b64encode(bytes(asset.content)).decode() + + values = { + "title": "СЕРТИФИКАТ", + "subtitle": "подтверждает участие", + "name": name, + "program": program_name, + "date": issued_at.strftime("%d.%m.%Y"), + "number": "№ " + number, + "director": snapshot["director"], + "position": snapshot["position"], + } + elements = [] + if assets.get("background"): + elements.append(f'') + for key, box in snapshot["layout"].items(): + if key in ("title", "subtitle") and not snapshot["show_text"]: + continue + style = f'left:{box["x"]}%;top:{box["y"]}%;width:{box["width"]}%;height:{box["height"]}%;' + if key == "signature": + if assets.get("signature"): + elements.append( + f'' + ) + else: + value = values[key] + # Conservative fit for long Cyrillic names and program titles, max three lines. + max_size = min(box["font_size"], box["height"] * 7.937 / 1.35) + size = max( + 8, min(max_size, (box["width"] * 11.22 * 2.5) / max(len(value), 1)) + ) + elements.append( + f'
{escape(value)}
' + ) + html = ( + """
""" + + "".join(elements) + + "
" + ) + return HTML(string=html, url_fetcher=fetch).write_pdf(font_config=FontConfiguration()) + + +def substitute(text, values): + for key, value in values.items(): + text = text.replace("{" + key + "}", str(value)) + return text + + +def send_certificate(certificate, *, test=False): + snapshot = certificate.batch.snapshot + values = { + "name": certificate.name, + "program": snapshot.get("program_name", certificate.program.name), + "date": certificate.created_at.strftime("%d.%m.%Y"), + "number": str(certificate.pk).upper(), + } + subject = substitute(snapshot["subject"], values) + body = substitute(snapshot["message"], values) + message = EmailMultiAlternatives( + ("Пробный сертификат · " if test else "") + subject, + body, + settings.EMAIL_USER, + [certificate.email], + ) + if test: + message.attach( + "certificate-preview.pdf", bytes(certificate.pdf), "application/pdf" + ) + else: + link = f"{certificate.batch.public_base_url}/programs/{certificate.program_id}/certificates/download/{certificate.download_token}/" + message.body += "\n\nСкачать сертификат: " + link + if not message.send(): + raise RuntimeError("Mail provider did not accept certificate") + + +@transaction.atomic +def initialize_batch(batch_id): + batch = ( + ProgramCertificateBatch.objects.select_for_update() + .select_related("program") + .get(pk=batch_id) + ) + if not batch.active or batch.issue_at > timezone.now() or batch.initialized_at: + return + if ( + batch.snapshot["timing"] == "scheduled" + and batch.program.datetime_finished > timezone.now() + ): + return + rows = [ + ProgramCertificate( + program=batch.program, + batch=batch, + user=user, + name=person_name(user), + email=user.email, + ) + for user in recipient_users(batch.program, batch.snapshot["audience"]) + ] + ProgramCertificate.objects.bulk_create(rows, ignore_conflicts=True, batch_size=500) + batch.initialized_at = timezone.now() + batch.save(update_fields=["initialized_at"]) + + +def dispatch(): + """Beat is the durable outbox; broker failures leave work eligible next minute.""" + from partner_programs.tasks import process_program_certificate + + for pk in ProgramCertificateBatch.objects.filter( + active=True, issue_at__lte=timezone.now(), initialized_at__isnull=True + ).values_list("pk", flat=True): + initialize_batch(pk) + stale = timezone.now() - timedelta(minutes=15) + ProgramCertificate.objects.filter(status="sending", claimed_at__lt=stale).update( + status="failed", + error="Отправка прервана. Проверьте результат и повторите при необходимости.", + queued_at=None, + ) + ProgramCertificate.objects.filter(status="generating", claimed_at__lt=stale).update( + status="pending", queued_at=None + ) + rows = ProgramCertificate.objects.filter(batch__active=True, status="pending").filter( + Q(queued_at__isnull=True) | Q(queued_at__lt=stale) + ) + for pk in list(rows.values_list("pk", flat=True)[:500]): + now = timezone.now() + if not rows.filter(pk=pk).update(queued_at=now): + continue + try: + process_program_certificate.delay(str(pk)) + except Exception: + ProgramCertificate.objects.filter( + pk=pk, status="pending", queued_at=now + ).update(queued_at=None) + logger.exception("Could not enqueue certificate %s", pk) + + +def process(certificate_id): + with transaction.atomic(): + row = ( + ProgramCertificate.objects.select_for_update() + .select_related("batch", "program") + .get(pk=certificate_id) + ) + if row.status != "pending" or not row.batch.active: + return + row.status = "generating" + row.claimed_at = timezone.now() + row.attempts += 1 + row.save(update_fields=["status", "claimed_at", "attempts"]) + try: + if not row.pdf: + batch = row.batch + row.pdf = render_pdf( + batch.snapshot, + {"background": batch.background, "signature": batch.signature}, + row.name, + batch.snapshot.get("program_name", row.program.name), + str(row.pk).upper(), + timezone.localtime(row.created_at), + ) + ProgramCertificate.objects.filter(pk=row.pk).update(pdf=row.pdf) + with transaction.atomic(): + # Lock the batch in the same order as pause/retry. A paused queue cannot send. + batch = ProgramCertificateBatch.objects.select_for_update().get( + pk=row.batch_id + ) + current = ProgramCertificate.objects.select_for_update().get(pk=row.pk) + if not batch.active: + current.status = "pending" + current.queued_at = None + current.save(update_fields=["status", "queued_at"]) + return + if not batch.snapshot["send_email"]: + current.status = "ready" + else: + # Persist before the provider call; a crash must not trigger automatic resend. + current.status = "sending" + current.save(update_fields=["status"]) + if batch.snapshot["send_email"]: + send_certificate(row) + ProgramCertificate.objects.filter(pk=row.pk).update( + status="sent", sent_at=timezone.now(), error="" + ) + except Exception: + logger.exception("Certificate processing failed: %s", certificate_id) + ProgramCertificate.objects.filter(pk=row.pk).update( + status="failed", + error="Не удалось создать PDF или отправить письмо. Повторите попытку.", + queued_at=None, + ) diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index a2080858..1aae8be2 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -6,6 +6,58 @@ logger = logging.getLogger(__name__) +@app.task +def dispatch_program_certificates(): + from partner_programs.services.certificates import dispatch + + return dispatch() + + +@app.task +def process_program_certificate(certificate_id): + from partner_programs.services.certificates import process + + return process(certificate_id) + + +@app.task +def send_test_program_certificate(program_id, user_id, snapshot): + from django.contrib.auth import get_user_model + from django.utils import timezone + from partner_programs.models import PartnerProgram + from partner_programs.certificate_models import ( + ProgramCertificate, + ProgramCertificateBatch, + ) + from partner_programs.services.certificates import ( + render_pdf, + send_certificate, + assets_for, + ) + from partner_programs.services.management import person_name + + program = PartnerProgram.objects.get(pk=program_id) + user = get_user_model().objects.get(pk=user_id) + batch = ProgramCertificateBatch(program=program, snapshot=snapshot) + row = ProgramCertificate( + program=program, + user=user, + batch=batch, + name=person_name(user), + email=user.email, + created_at=timezone.now(), + ) + row.pdf = render_pdf( + snapshot, + assets_for(program, snapshot), + row.name, + program.name, + "ПРОБНЫЙ", + row.created_at, + ) + send_certificate(row, test=True) + + @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) def send_registration_connection_notification(notification_id): from partner_programs.services.registration_notifications import deliver_notification diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py new file mode 100644 index 00000000..b9e261b6 --- /dev/null +++ b/partner_programs/tests/test_certificates.py @@ -0,0 +1,333 @@ +import io +from datetime import timedelta +from unittest.mock import patch + +from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings, tag +from django.utils import timezone +from PIL import Image +from rest_framework.test import APIClient + +from partner_programs.certificate_models import ( + ProgramCertificate, + ProgramCertificateBatch, +) +from partner_programs.services import certificates as service +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from projects.models import Collaborator + + +@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") +class CertificateTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(first_name="Ирина", last_name="Орлова") + cls.other = create_user() + cls.program = create_partner_program(is_competitive=True) + cls.program.managers.add(cls.manager) + cls.leader = create_user(first_name="Анна", last_name="Крылова") + cls.member = create_user() + cls.only_registered = create_user() + for user in (cls.leader, cls.member, cls.only_registered): + create_program_member(cls.program, user=user) + project = create_project(leader=cls.leader) + create_program_project(cls.program, project=project, submitted=True) + Collaborator.objects.bulk_create([Collaborator(project=project, user=cls.member)]) + # A profile project reference alone does not prove submitted team membership. + cls.only_registered.partner_program_profiles.filter( + partner_program=cls.program + ).update(project=project) + cls.foreign = create_partner_program(is_competitive=True) + create_program_project( + cls.foreign, + project=create_project(leader=cls.only_registered), + submitted=True, + ) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.root = f"/programs/{self.program.pk}/management/certificates/" + self.config = service.config_for(self.program) + + def batch(self, **overrides): + data = { + "program": self.program, + "snapshot": self.config.draft, + "issue_at": timezone.now(), + "public_base_url": "https://dev.procollab.ru", + } + data.update(overrides) + return ProgramCertificateBatch.objects.create(**data) + + def row(self, **overrides): + data = { + "program": self.program, + "batch": self.batch(), + "user": self.leader, + "name": "Анна Крылова", + "email": self.leader.email, + } + data.update(overrides) + return ProgramCertificate.objects.create(**data) + + def image(self, kind="PNG"): + buffer = io.BytesIO() + Image.new("RGBA" if kind == "PNG" else "RGB", (120, 60)).save(buffer, format=kind) + return SimpleUploadedFile( + "signature.png", buffer.getvalue(), content_type="image/png" + ) + + def test_access_requires_program_manager_and_does_not_leak_email(self): + for user, status in [ + (None, 401), + (self.other, 403), + (self.member, 403), + (self.manager, 200), + ]: + self.client.force_authenticate(user) + for path in ("", "deliveries/", "archive/"): + self.assertEqual(self.client.get(self.root + path).status_code, status) + self.client.force_authenticate(self.other) + self.assertEqual( + self.client.post(self.root + "activate/", {"revision": 1}).status_code, 403 + ) + self.assertEqual( + self.client.post( + self.root + "assets/", {"kind": "signature", "file": self.image()} + ).status_code, + 403, + ) + + def test_exact_two_audiences_deduplicate_and_scope_actual_team(self): + self.assertEqual( + set(service.recipient_users(self.program, "submitted")), + {self.leader, self.member}, + ) + self.assertEqual( + set(service.recipient_users(self.program, "all")), + {self.leader, self.member, self.only_registered}, + ) + state = self.client.get(self.root).data + self.assertEqual(state["eligible_counts"], {"all": 3, "submitted": 2}) + self.assertEqual(state["draft"]["director"], "") + + def test_revision_conflict_and_geometry_validation(self): + draft = service.default_draft() + response = self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ).status_code, + 409, + ) + draft["layout"]["name"]["x"] = 90 + self.assertEqual( + self.client.put( + self.root, {"revision": 2, "draft": draft}, format="json" + ).status_code, + 400, + ) + + def test_foreign_assets_and_arbitrary_template_tokens_rejected(self): + asset = service.upload_asset(self.foreign, "signature", self.image()) + draft = service.default_draft() + draft["signature_id"] = asset.pk + with self.assertRaises(Exception): + service.validate_draft(self.program, draft) + draft["signature_id"] = None + draft["subject"] = "{password}" + self.assertEqual( + self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ).status_code, + 400, + ) + self.assertEqual( + self.client.get(self.root + f"assets/{asset.pk}/").status_code, 404 + ) + + def test_images_validate_real_content_and_preserve_alpha(self): + response = self.client.post( + self.root + "assets/", {"kind": "signature", "file": self.image()} + ) + self.assertEqual(response.status_code, 201, response.data) + content = self.client.get(self.root + f"assets/{response.data['id']}/").content + self.assertEqual(Image.open(io.BytesIO(content)).mode, "RGBA") + for upload in ( + SimpleUploadedFile("bad.png", b"", content_type="image/png"), + self.image("JPEG"), + ): + self.assertEqual( + self.client.post( + self.root + "assets/", {"kind": "signature", "file": upload} + ).status_code, + 400, + ) + + def test_schedule_cannot_precede_finish_and_manual_needs_recipients(self): + self.config.draft["timing"] = "scheduled" + self.config.draft["scheduled_at"] = ( + timezone.now() + timedelta(hours=1) + ).isoformat() + self.config.save() + self.assertEqual( + self.client.post(self.root + "activate/", {"revision": 1}).status_code, 400 + ) + self.config.draft["scheduled_at"] = ( + self.program.datetime_finished + timedelta(hours=1) + ).isoformat() + self.config.save() + self.assertEqual( + self.client.post(self.root + "activate/", {"revision": 1}).status_code, 201 + ) + self.assertEqual( + self.client.post(self.root + "activate/", {"revision": 1}).status_code, 400 + ) + + def test_issuance_is_idempotent_across_runs_and_freezes_template(self): + batch = self.batch() + service.initialize_batch(batch.pk) + service.initialize_batch(batch.pk) + service.initialize_batch(self.batch().pk) + self.assertEqual(ProgramCertificate.objects.count(), 2) + self.config.draft["director"] = "Другой директор" + self.config.save() + self.assertEqual( + ProgramCertificate.objects.first().batch.snapshot["director"], "" + ) + + def test_rescheduled_finish_delays_automatic_initialization(self): + snapshot = service.default_draft() + snapshot["timing"] = "scheduled" + batch = self.batch(snapshot=snapshot) + service.initialize_batch(batch.pk) + self.assertFalse(ProgramCertificate.objects.exists()) + + @patch("partner_programs.services.certificates.render_pdf", return_value=b"%PDF-test") + def test_worker_generates_once_and_sends_real_service_mail(self, render): + row = self.row() + service.process(str(row.pk)) + service.process(str(row.pk)) + row.refresh_from_db() + self.assertEqual(row.status, "sent") + self.assertEqual(render.call_count, 1) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [self.leader.email]) + self.assertIn(str(row.download_token), mail.outbox[0].body) + + @patch("partner_programs.services.certificates.render_pdf", return_value=b"%PDF-test") + def test_failed_send_retry_uses_same_pdf_and_never_resends_sent(self, render): + row = self.row() + with patch( + "partner_programs.services.certificates.send_certificate", + side_effect=RuntimeError("provider"), + ): + service.process(str(row.pk)) + row.refresh_from_db() + self.assertEqual(row.status, "failed") + self.assertEqual(bytes(row.pdf), b"%PDF-test") + response = self.client.post(self.root + "retry/", {}) + self.assertEqual(response.data["count"], 1) + service.process(str(row.pk)) + self.assertEqual(render.call_count, 1) + self.assertEqual(self.client.post(self.root + "retry/", {}).data["count"], 0) + self.assertEqual(len(mail.outbox), 1) + + def test_pause_blocks_initialization_processing_and_retry(self): + row = self.row(status="failed") + self.assertEqual( + self.client.post(self.root + "queue/pause/", {}).status_code, 200 + ) + self.assertEqual(self.client.post(self.root + "retry/", {}).status_code, 400) + row.status = "pending" + row.save() + with patch("partner_programs.services.certificates.render_pdf") as render: + service.process(str(row.pk)) + render.assert_not_called() + self.assertEqual( + self.client.post(self.root + "queue/resume/", {}).status_code, 200 + ) + + def test_journal_filters_search_pagination_and_summary(self): + row = self.row(status="failed", pdf=b"%PDF", error="Временная ошибка") + self.row( + user=self.member, + email=self.member.email, + name="Илья", + status="sent", + pdf=b"%PDF", + sent_at=timezone.now(), + ) + response = self.client.get( + self.root + "deliveries/?status=failed&search=Крылова&limit=1" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["count"], 1) + self.assertEqual(str(response.data["results"][0]["id"]), str(row.pk)) + self.assertTrue(response.data["results"][0]["pdf_ready"]) + self.assertEqual( + response.data["summary"], {"total": 2, "ready": 2, "sent": 1, "failed": 1} + ) + self.assertEqual( + len(self.client.get(self.root + "deliveries/?status=sent").data["results"]), 1 + ) + self.assertEqual( + self.client.get(self.root + "deliveries/?status=unknown").status_code, 400 + ) + + def test_downloads_only_manager_owner_or_secret_link(self): + row = self.row(status="sent", pdf=b"%PDF-test") + path = self.root + f"deliveries/{row.pk}/pdf/" + self.assertEqual(self.client.get(path).status_code, 200) + self.client.force_authenticate(self.other) + self.assertEqual(self.client.get(path).status_code, 403) + self.assertEqual( + self.client.get(f"/programs/{self.program.pk}/certificates/me/").data, [] + ) + self.client.force_authenticate(self.leader) + own = self.client.get(f"/programs/{self.program.pk}/certificates/me/").data + self.assertEqual(len(own), 1) + self.client.force_authenticate(None) + link = f"/programs/{self.program.pk}/certificates/download/{row.download_token}/" + self.assertEqual(self.client.get(link).status_code, 200) + self.assertEqual( + self.client.get( + link.replace(str(self.program.pk), str(self.foreign.pk), 1) + ).status_code, + 404, + ) + + @patch("partner_programs.tasks.send_test_program_certificate.delay") + def test_test_email_is_only_self_and_rate_limited(self, task): + response = self.client.post( + self.root + "test/", {"revision": 1, "email": self.other.email} + ) + self.assertEqual(response.status_code, 202) + self.assertEqual(task.call_args.args[1], self.manager.pk) + self.assertEqual( + self.client.post(self.root + "test/", {"revision": 1}).status_code, 400 + ) + + @tag("native_pdf") + def test_pdf_renderer_outputs_a4_cyrillic_without_external_fetch(self): + content = service.render_pdf( + service.default_draft(), + {}, + "Александра Константиновна Добровольская", + "Программа развития", + "ПРОБНЫЙ", + timezone.now(), + ) + self.assertTrue(content.startswith(b"%PDF")) + self.assertGreater(len(content), 5000) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 8b301b50..21cef433 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -86,7 +86,49 @@ app_name = "partner_programs" +from partner_programs.certificate_views import ( + CertificateSettingsView, + CertificateAssetView, + CertificatePreviewView, + CertificateTestView, + CertificateActivateView, + CertificateQueueView, + CertificateJournalView, + CertificateRetryView, + CertificateDownloadView, + CertificateArchiveView, + MyCertificatesView, + PublicCertificateDownloadView, +) + urlpatterns = [ + path("/management/certificates/", CertificateSettingsView.as_view()), + path("/management/certificates/assets/", CertificateAssetView.as_view()), + path( + "/management/certificates/assets//", + CertificateAssetView.as_view(), + ), + path("/management/certificates/preview/", CertificatePreviewView.as_view()), + path("/management/certificates/test/", CertificateTestView.as_view()), + path("/management/certificates/activate/", CertificateActivateView.as_view()), + path( + "/management/certificates/queue//", + CertificateQueueView.as_view(), + ), + path( + "/management/certificates/deliveries/", CertificateJournalView.as_view() + ), + path("/management/certificates/retry/", CertificateRetryView.as_view()), + path( + "/management/certificates/deliveries//pdf/", + CertificateDownloadView.as_view(), + ), + path("/management/certificates/archive/", CertificateArchiveView.as_view()), + path("/certificates/me/", MyCertificatesView.as_view()), + path( + "/certificates/download//", + PublicCertificateDownloadView.as_view(), + ), path( "/management/projects/actions/preview/", ManagementProjectActionPreviewView.as_view(), diff --git a/procollab/celery.py b/procollab/celery.py index 7ca425e9..71d371c2 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -13,6 +13,10 @@ app.conf.task_serializer = "json" app.conf.beat_schedule = { + "program_certificates": { + "task": "partner_programs.tasks.dispatch_program_certificates", + "schedule": crontab(minute="*"), + }, "program_attention_reminders": { "task": "partner_programs.tasks.dispatch_attention_reminders", "schedule": crontab(minute="*"), From cf2096e0d66a87dcaa8e789eedf9c11c84e9ed36 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 00:31:28 +0300 Subject: [PATCH 2/4] fix: satisfy certificate API lint checks --- partner_programs/certificate_views.py | 6 +++--- partner_programs/services/certificates.py | 5 ++++- partner_programs/urls.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/partner_programs/certificate_views.py b/partner_programs/certificate_views.py index 8e96a78e..0b3e2002 100644 --- a/partner_programs/certificate_views.py +++ b/partner_programs/certificate_views.py @@ -210,6 +210,8 @@ def get(self, request, pk): sent=Count("pk", filter=Q(status="sent")), failed=Count("pk", filter=Q(status="failed")), ) + offset = query["offset"] + end = offset + query["limit"] result = [ { "id": row.pk, @@ -220,9 +222,7 @@ def get(self, request, pk): "error": row.error, "sent_at": row.sent_at, } - for row in rows.annotate(pdf_ready=~Q(pdf__isnull=True))[ - query["offset"] : query["offset"] + query["limit"] - ] + for row in rows.annotate(pdf_ready=~Q(pdf__isnull=True))[offset:end] ] return Response({"count": rows.count(), "summary": summary, "results": result}) diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index bc3e1e55..f8fe2ddb 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -353,7 +353,10 @@ def send_certificate(certificate, *, test=False): "certificate-preview.pdf", bytes(certificate.pdf), "application/pdf" ) else: - link = f"{certificate.batch.public_base_url}/programs/{certificate.program_id}/certificates/download/{certificate.download_token}/" + link = ( + f"{certificate.batch.public_base_url}/programs/{certificate.program_id}" + f"/certificates/download/{certificate.download_token}/" + ) message.body += "\n\nСкачать сертификат: " + link if not message.send(): raise RuntimeError("Mail provider did not accept certificate") diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 21cef433..14193bc3 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -84,8 +84,6 @@ ManagementResultsExportView, ) -app_name = "partner_programs" - from partner_programs.certificate_views import ( CertificateSettingsView, CertificateAssetView, @@ -101,6 +99,8 @@ PublicCertificateDownloadView, ) +app_name = "partner_programs" + urlpatterns = [ path("/management/certificates/", CertificateSettingsView.as_view()), path("/management/certificates/assets/", CertificateAssetView.as_view()), From 1d6a722c9d2ee529799534f677f29a8868166fc0 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 00:34:16 +0300 Subject: [PATCH 3/4] test: verify certificate recovery and native PDF content --- .github/workflows/backend-postgres-ci.yml | 2 +- partner_programs/certificate_models.py | 6 +- ...asset_programcertificateconfig_and_more.py | 6 +- partner_programs/services/certificates.py | 11 +++- partner_programs/tests/test_certificates.py | 57 +++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index cc828a37..e38b5c51 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -60,7 +60,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 fontconfig + sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 fontconfig poppler-utils python -m pip install "poetry==1.2.2" poetry config virtualenvs.create true poetry install --no-interaction --no-ansi --no-root diff --git a/partner_programs/certificate_models.py b/partner_programs/certificate_models.py index af9fbe89..d1409756 100644 --- a/partner_programs/certificate_models.py +++ b/partner_programs/certificate_models.py @@ -31,13 +31,13 @@ class ProgramCertificateBatch(models.Model): background = models.ForeignKey( ProgramCertificateAsset, null=True, - on_delete=models.PROTECT, + on_delete=models.RESTRICT, related_name="background_batches", ) signature = models.ForeignKey( ProgramCertificateAsset, null=True, - on_delete=models.PROTECT, + on_delete=models.RESTRICT, related_name="signature_batches", ) active = models.BooleanField(default=True) @@ -52,7 +52,7 @@ class ProgramCertificate(models.Model): download_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) program = models.ForeignKey("PartnerProgram", on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - batch = models.ForeignKey(ProgramCertificateBatch, on_delete=models.PROTECT) + batch = models.ForeignKey(ProgramCertificateBatch, on_delete=models.RESTRICT) name = models.CharField(max_length=500) email = models.EmailField() pdf = models.BinaryField(null=True) diff --git a/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py b/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py index 185fd620..9a99ac05 100644 --- a/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py +++ b/partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py @@ -47,9 +47,9 @@ class Migration(migrations.Migration): ('initialized_at', models.DateTimeField(null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('public_base_url', models.URLField()), - ('background', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='background_batches', to='partner_programs.programcertificateasset')), + ('background', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='background_batches', to='partner_programs.programcertificateasset')), ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')), - ('signature', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='signature_batches', to='partner_programs.programcertificateasset')), + ('signature', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='signature_batches', to='partner_programs.programcertificateasset')), ], ), migrations.CreateModel( @@ -67,7 +67,7 @@ class Migration(migrations.Migration): ('claimed_at', models.DateTimeField(null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('sent_at', models.DateTimeField(null=True)), - ('batch', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='partner_programs.programcertificatebatch')), + ('batch', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='partner_programs.programcertificatebatch')), ('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index f8fe2ddb..58d275da 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -204,8 +204,8 @@ def validate_draft(program, draft): from rest_framework import serializers class Element(serializers.Serializer): - x = serializers.FloatField(min_value=0, max_value=100) - y = serializers.FloatField(min_value=0, max_value=100) + x = serializers.FloatField(min_value=0, max_value=100) # noqa: VNE001 + y = serializers.FloatField(min_value=0, max_value=100) # noqa: VNE001 width = serializers.FloatField(min_value=2, max_value=100) height = serializers.FloatField(min_value=1, max_value=100) font_size = serializers.FloatField(min_value=8, max_value=72) @@ -230,6 +230,10 @@ class Draft(serializers.Serializer): if set(data["layout"]) != set(ELEMENTS): raise ValidationError("Состав элементов шаблона изменен. Обновите страницу.") for element in data["layout"].values(): + import math + + if not all(math.isfinite(value) for value in element.values()): + raise ValidationError("Размеры и положение должны быть конечными числами.") if ( element["x"] + element["width"] > 100.01 or element["y"] + element["height"] > 100.01 @@ -337,10 +341,11 @@ def send_certificate(certificate, *, test=False): values = { "name": certificate.name, "program": snapshot.get("program_name", certificate.program.name), - "date": certificate.created_at.strftime("%d.%m.%Y"), + "date": timezone.localtime(certificate.created_at).strftime("%d.%m.%Y"), "number": str(certificate.pk).upper(), } subject = substitute(snapshot["subject"], values) + subject = subject.replace("\r", " ").replace("\n", " ") body = substitute(snapshot["message"], values) message = EmailMultiAlternatives( ("Пробный сертификат · " if test else "") + subject, diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py index b9e261b6..e71acab7 100644 --- a/partner_programs/tests/test_certificates.py +++ b/partner_programs/tests/test_certificates.py @@ -331,3 +331,60 @@ def test_pdf_renderer_outputs_a4_cyrillic_without_external_fetch(self): ) self.assertTrue(content.startswith(b"%PDF")) self.assertGreater(len(content), 5000) + import shutil + import subprocess + import tempfile + from pathlib import Path + + if shutil.which("pdftotext"): + with tempfile.TemporaryDirectory() as folder: + pdf = Path(folder) / "certificate.pdf" + pdf.write_bytes(content) + text = subprocess.check_output( + ["pdftotext", str(pdf), "-"], encoding="utf-8" + ) + for word in ( + "Александра", + "Константиновна", + "Добровольская", + "Программа", + "развития", + ): + self.assertIn(word, text) + info = subprocess.check_output(["pdfinfo", str(pdf)], encoding="utf-8") + self.assertRegex(info, r"Pages:\s+1\b") + self.assertRegex(info, r"Page size:\s+841\.\d+ x 595\.\d+") + + def test_program_deletion_still_cascades_its_certificates_and_assets(self): + asset = service.upload_asset(self.program, "signature", self.image()) + batch = self.batch(signature=asset) + self.row(batch=batch) + self.program.delete() + self.assertFalse(ProgramCertificate.objects.exists()) + + @patch( + "partner_programs.tasks.process_program_certificate.delay", + side_effect=RuntimeError("broker offline"), + ) + def test_dispatch_recovers_broker_failure_without_losing_work(self, task): + row = self.row() + row.batch.initialized_at = timezone.now() + row.batch.save() + service.dispatch() + row.refresh_from_db() + self.assertEqual(row.status, "pending") + self.assertIsNone(row.queued_at) + + @patch("partner_programs.tasks.process_program_certificate.delay") + def test_ambiguous_send_is_not_automatically_resent(self, task): + row = self.row( + status="sending", + claimed_at=timezone.now() - timedelta(minutes=20), + pdf=b"%PDF", + ) + row.batch.initialized_at = timezone.now() + row.batch.save() + service.dispatch() + row.refresh_from_db() + self.assertEqual(row.status, "failed") + task.assert_not_called() From 48be948d3d1eb43b6d2779e974b6e6dc38fb58cf Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 24 Oct 2026 00:37:29 +0300 Subject: [PATCH 4/4] test: cover concurrent certificate workers and issuance --- partner_programs/tests/test_certificates.py | 62 ++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py index e71acab7..99f4ca42 100644 --- a/partner_programs/tests/test_certificates.py +++ b/partner_programs/tests/test_certificates.py @@ -4,7 +4,13 @@ from django.core import mail from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase, override_settings, tag +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + tag, + skipUnlessDBFeature, +) from django.utils import timezone from PIL import Image from rest_framework.test import APIClient @@ -388,3 +394,57 @@ def test_ambiguous_send_is_not_automatically_resent(self, task): row.refresh_from_db() self.assertEqual(row.status, "failed") task.assert_not_called() + + +class CertificateConcurrencyTests(TransactionTestCase): + def setUp(self): + self.program = create_partner_program() + self.user = create_user() + create_program_member(self.program, user=self.user) + draft = service.default_draft() + draft["audience"] = "all" + self.batch = ProgramCertificateBatch.objects.create( + program=self.program, + snapshot=draft, + issue_at=timezone.now(), + public_base_url="https://dev.procollab.ru", + ) + + def parallel(self, operation): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + from django.db import close_old_connections + + barrier = Barrier(2) + + def run(): + close_old_connections() + try: + barrier.wait(timeout=10) + operation() + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(run) for _ in range(2)] + for future in futures: + future.result(timeout=20) + + @skipUnlessDBFeature("has_select_for_update") + def test_concurrent_initialization_has_one_certificate_per_participant(self): + self.parallel(lambda: service.initialize_batch(self.batch.pk)) + self.assertEqual( + ProgramCertificate.objects.filter(program=self.program).count(), 1 + ) + + @skipUnlessDBFeature("has_select_for_update") + @patch("partner_programs.services.certificates.render_pdf", return_value=b"%PDF") + @patch("partner_programs.services.certificates.send_certificate") + def test_concurrent_workers_send_once(self, send, render): + service.initialize_batch(self.batch.pk) + row = ProgramCertificate.objects.get(program=self.program) + self.parallel(lambda: service.process(str(row.pk))) + row.refresh_from_db() + self.assertEqual(row.status, "sent") + send.assert_called_once() + render.assert_called_once()