Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/backend-postgres-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down
57 changes: 57 additions & 0 deletions docs/modules/program-certificates.md
Original file line number Diff line number Diff line change
@@ -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 в программе и ссылка в сервисном письме; защита чужих документов.
72 changes: 72 additions & 0 deletions partner_programs/certificate_models.py
Original file line number Diff line number Diff line change
@@ -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"
)
]
Loading
Loading