diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml
index d07e6269..e38b5c51 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 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/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..d1409756
--- /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.RESTRICT,
+ related_name="background_batches",
+ )
+ signature = models.ForeignKey(
+ ProgramCertificateAsset,
+ null=True,
+ on_delete=models.RESTRICT,
+ 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.RESTRICT)
+ 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..0b3e2002
--- /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")),
+ )
+ offset = query["offset"]
+ end = offset + query["limit"]
+ 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))[offset:end]
+ ]
+ 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..9a99ac05
--- /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.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.RESTRICT, 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.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)),
+ ],
+ ),
+ 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..58d275da
--- /dev/null
+++ b/partner_programs/services/certificates.py
@@ -0,0 +1,485 @@
+"""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) # 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)
+
+ 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():
+ 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
+ ):
+ 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'