diff --git a/docs/modules/program-certificates.md b/docs/modules/program-certificates.md index 141ae3f5..2b3db324 100644 --- a/docs/modules/program-certificates.md +++ b/docs/modules/program-certificates.md @@ -45,6 +45,26 @@ Celery beat раз в минуту подбирает запуски и сохр PDF использует уже имеющиеся WeasyPrint и Mont. Загрузчик PDF запрещает сеть и произвольные файлы. Поля письма поддерживают только перечисленные подстановки. +## Готовый фон и видимость элементов + +В `draft` добавлен необязательный `hidden_elements`: список ключей `title`, +`subtitle`, `program`, `date`, `number`, `director`, `position`, `signature`. +Имя обязательно; `name`, неизвестные ключи, повторы и null отклоняются с 400. +Все девять блоков `layout` сохраняются, включая геометрию скрытых элементов. +GET/PUT возвращают нормализованный список; права и revision/409 не менялись. + +Новые настройки содержат только имя по центру A4. Существующие JSON без нового +поля сохраняют видимость и координаты; `show_text=false` по-прежнему скрывает +заголовок и подзаголовок. PUT старого клиента без `hidden_elements` сохраняет +текущую видимость, а явное переключение legacy `show_text` меняет эти два блока. +Новый Angular редактор нормализует legacy-флаг и управляет каждым блоком отдельно. + +Готовый PNG/JPG можно использовать целиком, оставив поверх только имя. +Preview и новые снимки выдачи используют одинаковую видимость; текст Mont +переносится и подгоняется по реальной ширине и высоте области. Ранее выданные +PDF не изменяются, миграция БД не требуется. Порядок внедрения: API, затем +парный Angular PR; обновленные клиенты требуют поддержки нового поля сервером. + ## Приемка 1. Новая вкладка доступна менеджеру, чужая программа возвращает 403. diff --git a/partner_programs/certificate_views.py b/partner_programs/certificate_views.py index 0b3e2002..7b78ee6a 100644 --- a/partner_programs/certificate_views.py +++ b/partner_programs/certificate_views.py @@ -53,7 +53,9 @@ def put(self, request, pk): 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.draft = service.validate_draft( + program, request.data.get("draft"), config.draft + ) config.revision += 1 config.save(update_fields=["revision", "draft"]) return Response(service.state(program)) diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index 58d275da..907c1461 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -3,6 +3,7 @@ import base64 import io import logging +from functools import lru_cache from datetime import timedelta from html import escape from pathlib import Path @@ -12,7 +13,7 @@ from django.db import transaction from django.db.models import Exists, OuterRef, Q from django.utils import timezone -from PIL import Image, UnidentifiedImageError +from PIL import Image, ImageFont, UnidentifiedImageError from rest_framework.exceptions import ValidationError from partner_programs.certificate_models import ( @@ -43,13 +44,13 @@ 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), + (15, 42, 70, 16, 40), + (10, 60, 80, 7, 20), + (10, 80, 30, 5, 14), + (10, 86, 35, 5, 12), + (57, 80, 33, 5, 18), + (57, 86, 33, 5, 12), + (60, 70, 25, 8, 16), ] return { "background_id": None, @@ -57,6 +58,7 @@ def default_draft(): "director": "", "position": "", "show_text": True, + "hidden_elements": [key for key in ELEMENTS if key != "name"], "audience": "submitted", "timing": "manual", "scheduled_at": None, @@ -70,6 +72,22 @@ def default_draft(): } +def hidden_elements(draft): + """Старые черновики и снимки выдачи сохраняют прежнее отображение.""" + hidden = set(draft.get("hidden_elements", [])) + if not draft.get("show_text", True): + hidden.update(("title", "subtitle")) + hidden.discard("name") + return hidden + + +def normalized_draft(draft): + return { + **draft, + "hidden_elements": [key for key in ELEMENTS if key in hidden_elements(draft)], + } + + def recipients(program, audience): profiles = PartnerProgramUserProfile.objects.filter( partner_program=program, user__isnull=False @@ -139,7 +157,7 @@ def state(program): ) return { "revision": config.revision, - "draft": config.draft, + "draft": normalized_draft(config.draft), "assets": { key: asset_data(value) for key, value in assets_for(program, config.draft).items() @@ -199,7 +217,7 @@ def upload_asset(program, kind, upload): ) -def validate_draft(program, draft): +def validate_draft(program, draft, previous=None): """The API owns allowed geometry and substitutions, not arbitrary HTML/CSS.""" from rest_framework import serializers @@ -216,6 +234,12 @@ class Draft(serializers.Serializer): director = serializers.CharField(max_length=250, allow_blank=True) position = serializers.CharField(max_length=250, allow_blank=True) show_text = serializers.BooleanField() + hidden_elements = serializers.ListField( + child=serializers.ChoiceField( + choices=[key for key in ELEMENTS if key != "name"] + ), + required=False, + ) audience = serializers.ChoiceField(choices=["all", "submitted"]) timing = serializers.ChoiceField(choices=["manual", "scheduled"]) scheduled_at = serializers.DateTimeField(allow_null=True) @@ -227,6 +251,17 @@ class Draft(serializers.Serializer): serializer = Draft(data=draft) serializer.is_valid(raise_exception=True) data = serializer.data + if "hidden_elements" not in data: + # Старый клиент не должен восстанавливать удаленные новым редактором элементы. + hidden = hidden_elements(previous or data) + if previous and data["show_text"] != previous.get("show_text", True): + if data["show_text"]: + hidden.difference_update(("title", "subtitle")) + else: + hidden.update(("title", "subtitle")) + data["hidden_elements"] = [key for key in ELEMENTS if key in hidden] + elif len(data["hidden_elements"]) != len(set(data["hidden_elements"])): + raise ValidationError({"hidden_elements": "Элементы не должны повторяться."}) if set(data["layout"]) != set(ELEMENTS): raise ValidationError("Состав элементов шаблона изменен. Обновите страницу.") for element in data["layout"].values(): @@ -252,7 +287,56 @@ class Draft(serializers.Serializer): "Доступные подстановки: {name}, {program}, {date}, {number}." ) assets_for(program, data) - return dict(data) + return normalized_draft(dict(data)) + + +@lru_cache(maxsize=4) +def certificate_font(bold): + filename = "Mont-SemiBold.ttf" if bold else "Mont-Regular.ttf" + return ImageFont.truetype( + str(Path(settings.BASE_DIR) / "assets/users/user_CV" / filename), 1000 + ) + + +def fit_certificate_text(value, box, bold=False): + """Подгоняем реальные строки Mont по обеим сторонам бокса, без обрезки ФИО.""" + font = certificate_font(bold) + width = max(1, box["width"] * 11.22 - 2) + height = max(1, box["height"] * 7.937 - 2) + + def lines_at(size): + def fits(text): + return font.getlength(text) / 1000 * size * 1.02 <= width + + lines = [] + for paragraph in str(value).split("\n"): + line = "" + for word in paragraph.split(): + candidate = (line + " " + word).lstrip() + if fits(candidate): + line = candidate + continue + if line: + lines.append(line) + line = "" + for char in word: + if line and not fits(line + char): + lines.append(line) + line = "" + line += char + lines.append(line) + return lines + + low, high = 0.5, float(box["font_size"]) + for _ in range(18): + size = (low + high) / 2 + lines = lines_at(size) + if len(lines) * size * 1.3 <= height: + low = size + else: + high = size + size = min(float(box["font_size"]), low) + return size, lines_at(size) def render_pdf(snapshot, assets, name, program_name, number, issued_at): @@ -293,10 +377,11 @@ def src(asset): "position": snapshot["position"], } elements = [] + hidden = hidden_elements(snapshot) if assets.get("background"): elements.append(f'') for key, box in snapshot["layout"].items(): - if key in ("title", "subtitle") and not snapshot["show_text"]: + if key in hidden: continue style = f'left:{box["x"]}%;top:{box["y"]}%;width:{box["width"]}%;height:{box["height"]}%;' if key == "signature": @@ -306,13 +391,10 @@ def src(asset): ) 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)) - ) + size, lines = fit_certificate_text(value, box, key in ("name", "title")) + content = "
".join(escape(line) for line in lines) elements.append( - f'
{escape(value)}
' + f'
{content}
' ) html = ( """
""" + "".join(elements) diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py index 99f4ca42..06ef949e 100644 --- a/partner_programs/tests/test_certificates.py +++ b/partner_programs/tests/test_certificates.py @@ -1,4 +1,5 @@ import io +from copy import deepcopy from datetime import timedelta from unittest.mock import patch @@ -14,6 +15,7 @@ from django.utils import timezone from PIL import Image from rest_framework.test import APIClient +from rest_framework.exceptions import ValidationError from partner_programs.certificate_models import ( ProgramCertificate, @@ -145,6 +147,135 @@ def test_revision_conflict_and_geometry_validation(self): 400, ) + def test_new_template_has_only_required_name_centered(self): + draft = self.client.get(self.root).data["draft"] + self.assertEqual(set(draft["hidden_elements"]), set(service.ELEMENTS) - {"name"}) + box = draft["layout"]["name"] + self.assertEqual(box["x"] + box["width"] / 2, 50) + self.assertEqual(box["y"] + box["height"] / 2, 50) + rows = [ + draft["layout"][key] + for key in ( + "title", + "subtitle", + "name", + "program", + "signature", + "director", + "position", + ) + ] + for upper, lower in zip(rows, rows[1:]): + self.assertLessEqual(upper["y"] + upper["height"], lower["y"]) + + def test_optional_elements_can_be_hidden_independently_but_name_cannot(self): + for key in service.ELEMENTS: + draft = service.default_draft() + draft["hidden_elements"] = [key] + if key == "name": + with self.assertRaises(ValidationError): + service.validate_draft(self.program, draft) + else: + self.assertEqual( + service.validate_draft(self.program, draft)["hidden_elements"], [key] + ) + for invalid in (["unknown"], ["date", "date"], None): + draft["hidden_elements"] = invalid + response = self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 400, response.data) + self.config.refresh_from_db() + self.assertEqual(self.config.revision, 1) + + def test_legacy_draft_keeps_visibility_and_geometry_without_migration(self): + draft = service.default_draft() + draft.pop("hidden_elements") + draft["layout"]["name"]["x"] = 7 + draft["layout"]["name"]["width"] = 80 + draft["show_text"] = False + self.config.draft = draft + self.config.save() + result = self.client.get(self.root).data["draft"] + self.assertEqual(result["hidden_elements"], ["title", "subtitle"]) + self.assertEqual(result["layout"], draft["layout"]) + self.config.refresh_from_db() + self.assertNotIn("hidden_elements", self.config.draft) + + def test_old_client_save_does_not_restore_hidden_elements(self): + draft = deepcopy(self.config.draft) + draft.pop("hidden_elements") + draft["director"] = "Другой директор" + response = self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + set(response.data["draft"]["hidden_elements"]), + set(service.ELEMENTS) - {"name"}, + ) + + def test_text_fit_uses_real_font_and_both_dimensions(self): + box = {"width": 70, "height": 12, "font_size": 40} + value = "Александра Константиновна Добровольская" + size, lines = service.fit_certificate_text(value, box, True) + self.assertEqual(" ".join(lines), value) + self.assertLessEqual(len(lines) * size * 1.3, box["height"] * 7.937 - 2) + font = service.certificate_font(True) + for line in lines: + self.assertLessEqual( + font.getlength(line) / 1000 * size * 1.02, 70 * 11.22 - 2 + ) + + @tag("native_pdf") + def test_ready_template_pdf_adds_only_each_participants_name(self): + from weasyprint import HTML + + draft = service.default_draft() + draft["director"] = "Директор для проверки" + background = service.upload_asset(self.program, "background", self.image()) + assets = {"background": background} + with patch("weasyprint.HTML", wraps=HTML) as render: + first = service.render_pdf( + draft, + assets, + "Анна Крылова", + "Скрытая программа", + "СКРЫТЫЙ НОМЕР", + timezone.now(), + ) + first_html = render.call_args.kwargs["string"] + second = service.render_pdf( + draft, + assets, + "Илья Морозов", + "Скрытая программа", + "СКРЫТЫЙ НОМЕР", + timezone.now(), + ) + second_html = render.call_args.kwargs["string"] + self.assertTrue(first.startswith(b"%PDF")) + self.assertTrue(second.startswith(b"%PDF")) + self.assertIn("Анна Крылова", first_html) + self.assertIn("Илья Морозов", second_html) + import re + + first_background = re.search( + r'