diff --git a/assets/users/user_CV/Mont-Bold.ttf b/assets/users/user_CV/Mont-Bold.ttf new file mode 100644 index 00000000..4952bac4 Binary files /dev/null and b/assets/users/user_CV/Mont-Bold.ttf differ diff --git a/assets/users/user_CV/Mont-Light.ttf b/assets/users/user_CV/Mont-Light.ttf new file mode 100644 index 00000000..86bfb258 Binary files /dev/null and b/assets/users/user_CV/Mont-Light.ttf differ diff --git a/docker-compose.dev-ci.yml b/docker-compose.dev-ci.yml index 77de9869..1058d9cd 100644 --- a/docker-compose.dev-ci.yml +++ b/docker-compose.dev-ci.yml @@ -14,6 +14,8 @@ services: environment: HOST: 0.0.0.0 EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru + PROGRAM_SOLUTIONS_ENABLED: ${PROGRAM_SOLUTIONS_ENABLED:-false} + PROJECT_DEFAULT_PACK_URL: https://dev.procollab.ru/assets/project-defaults ports: - "127.0.0.1:8000:8000" @@ -34,6 +36,7 @@ services: - .env environment: EXPERT_WORKSPACE_FRONTEND_URL: https://dev.procollab.ru + PROGRAM_SOLUTIONS_ENABLED: ${PROGRAM_SOLUTIONS_ENABLED:-false} command: bash ./scripts/celery.sh depends_on: - redis diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 11ace654..77633333 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -30,6 +30,7 @@ from partner_programs.services.case_fields import case_field_has_values from partner_programs.admin_registration import ProgramRegistrationAdminMixin from partner_programs.admin_publication import ProgramPublicationAdminMixin +from partner_programs.admin_certificate_fonts import ProgramCertificateFontsAdminMixin from partner_programs.program_dates import ( DATE_FIELDS, program_date_errors, @@ -53,7 +54,10 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin( - ProgramPublicationAdminMixin, ProgramRegistrationAdminMixin, admin.ModelAdmin + ProgramPublicationAdminMixin, + ProgramRegistrationAdminMixin, + ProgramCertificateFontsAdminMixin, + admin.ModelAdmin, ): class PartnerProgramAdminForm(forms.ModelForm): confirm_reopen = forms.BooleanField( @@ -130,6 +134,7 @@ class Meta: "is_competitive", "legacy_team_min_size", "legacy_team_max_size", + "project_sections", "projects_availability", "publish_projects_after_finish", "max_project_rates", @@ -398,13 +403,18 @@ def get_deleted_objects(self, objs, request): objs, request ) for obj in objs: + from partner_programs.project_form import protected_project_field from partner_programs.solution_forms import answer_count - if case_field_has_values(obj) or ( - obj.partner_program.solution_storage_mode == "solution" - and answer_count(obj) + if ( + protected_project_field(obj) + or case_field_has_values(obj) + or ( + obj.partner_program.solution_storage_mode == "solution" + and answer_count(obj) + ) ): - protected.append(f"Используемое поле кейса: {obj}") + protected.append(f"Стандартное поле или поле с ответами: {obj}") return deleted, counts, permissions_needed, protected list_display = ( @@ -414,6 +424,7 @@ def get_deleted_objects(self, objs, request): "label", "field_type", "is_required", + "is_visible", "show_filter", ) list_filter = ("partner_program",) diff --git a/partner_programs/admin_certificate_fonts.py b/partner_programs/admin_certificate_fonts.py new file mode 100644 index 00000000..be039f86 --- /dev/null +++ b/partner_programs/admin_certificate_fonts.py @@ -0,0 +1,122 @@ +from copy import deepcopy + +from django import forms +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from rest_framework.exceptions import ValidationError + +from partner_programs.certificate_models import ( + ProgramCertificateAsset, + ProgramCertificateConfig, +) +from partner_programs.services import certificates + + +class CertificateFontsForm(forms.Form): + element = forms.ChoiceField( + label="Текстовый элемент", + choices=[ + ("name", "Имя участника"), + ("title", "Заголовок"), + ("subtitle", "Подзаголовок"), + ("program", "Название программы"), + ("date", "Дата выдачи"), + ("number", "Номер сертификата"), + ("director", "Имя директора"), + ("position", "Должность"), + ], + ) + font = forms.ChoiceField(label="Шрифт") + upload = forms.FileField( + label="Загрузить шрифт для выбранного элемента", + required=False, + widget=forms.ClearableFileInput(attrs={"accept": ".ttf,.otf"}), + help_text="TTF или OTF до 2 МБ.", + ) + revision = forms.IntegerField(widget=forms.HiddenInput()) + + def __init__(self, *args, program, config, **kwargs): + super().__init__(*args, **kwargs) + self.fields["revision"].initial = config.revision + self.fields["font"].choices = [ + ("mont", "Mont"), + ("mont-regular", "Mont Regular"), + ("mont-light", "Mont Light"), + ("mont-bold", "Mont Bold"), + ("pt-serif", "PT Serif"), + ("lobster", "Lobster"), + *[ + (f"custom-{pk}", name) + for pk, name in ProgramCertificateAsset.objects.filter( + program=program, kind="font" + ).values_list("pk", "name") + ], + ] + self.fields["font"].initial = config.draft["layout"]["name"].get( + "font_family", "mont" + ) + + +class ProgramCertificateFontsAdminMixin: + def get_urls(self): + return [ + path( + "/certificate-fonts/", + self.admin_site.admin_view(self.certificate_fonts), + name="partner_programs_partnerprogram_certificate_fonts", + ), + *super().get_urls(), + ] + + def certificate_fonts(self, request, object_id): + program = get_object_or_404(self.get_queryset(request), pk=object_id) + if not self.has_change_permission(request, program): + raise PermissionDenied + config = certificates.config_for(program) + form = CertificateFontsForm( + request.POST or None, request.FILES or None, program=program, config=config + ) + if request.method == "POST" and form.is_valid(): + try: + with transaction.atomic(): + type(program).objects.select_for_update().get(pk=program.pk) + current = ProgramCertificateConfig.objects.select_for_update().get( + pk=config.pk + ) + data = form.cleaned_data + if current.revision != data["revision"]: + raise ValidationError("Шаблон уже изменен. Обновите страницу.") + font = data["font"] + if data["upload"]: + font = f"custom-{certificates.upload_font(program, data['upload']).pk}" + draft = deepcopy(current.draft) + draft["layout"][data["element"]]["font_family"] = font + current.draft = certificates.validate_draft( + program, draft, current.draft + ) + current.revision += 1 + current.save(update_fields=["draft", "revision"]) + except ValidationError as error: + form.add_error(None, " ".join(map(str, error.detail))) + else: + self.message_user(request, "Шрифт элемента сохранен.", messages.SUCCESS) + return redirect( + "admin:partner_programs_partnerprogram_certificate_fonts", object_id + ) + return TemplateResponse( + request, + "partner_programs/admin/certificate_fonts.html", + { + **self.admin_site.each_context(request), + "title": "Шрифты сертификата", + "program": program, + "form": form, + "change_url": reverse( + "admin:partner_programs_partnerprogram_change", args=[object_id] + ), + }, + ) diff --git a/partner_programs/attention_views.py b/partner_programs/attention_views.py index b2c38b8e..29014d4c 100644 --- a/partner_programs/attention_views.py +++ b/partner_programs/attention_views.py @@ -36,7 +36,13 @@ def post(self, request, pk, category, action): if action == "reminders": response = Response( confirm_reminder( - program, category, request.user, serializer.validated_data["token"] + program, + category, + request.user, + serializer.validated_data["token"], + excluded_recipient_ids=serializer.validated_data[ + "excluded_recipient_ids" + ], ) ) elif action == "reminder-preview": diff --git a/partner_programs/certificate_views.py b/partner_programs/certificate_views.py index 9510cd01..5d65b3a0 100644 --- a/partner_programs/certificate_views.py +++ b/partner_programs/certificate_views.py @@ -72,7 +72,8 @@ def post(self, request, pk): 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") + content_type = "font/sfnt" if asset.kind == "font" else "image/png" + response = HttpResponse(bytes(asset.content), content_type=content_type) response["Cache-Control"] = "private, no-store" response["X-Content-Type-Options"] = "nosniff" return response @@ -93,6 +94,7 @@ def post(self, request, pk): program.name, "ПРОБНЫЙ", timezone.localtime(), + program_id=program.pk, ) return pdf_response(content, "certificate-preview") @@ -384,6 +386,7 @@ def current_certificate_pdf(program, config, row, public_base_url): program.name, str(row.pk).upper(), timezone.localtime(row.created_at), + program_id=program.pk, ) row.batch = service.manual_batch(program, draft, public_base_url, "refresh") row.rendered_batch = row.batch @@ -430,13 +433,21 @@ def post(self, request, pk): program = managed_program(pk, request.user, lock=True) config = service.config_for(program) - revision_check(config, request.data) rows = ( selected_rows(program, request.data) .select_for_update(of=("self",)) .select_related("batch", "rendered_batch", "program") .defer(None) ) + published_only = serializers.BooleanField(default=False).run_validation( + request.data.get("published_only", False) + ) + if not published_only: + revision_check(config, request.data) + if published_only and rows.filter(pdf__isnull=True).exists(): + raise ValidationError( + "Сначала подготовьте PDF для всех выбранных участников." + ) if rows.filter(status__in=["pending", "generating", "sending"]).exists(): raise ValidationError( "Дождитесь завершения обработки выбранных сертификатов." @@ -444,11 +455,19 @@ def post(self, request, pk): output = tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) try: with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + names = set() for row in rows.iterator(chunk_size=20): - content = current_certificate_pdf( - program, config, row, request.build_absolute_uri("/").rstrip("/") + content = ( + bytes(row.pdf) + if published_only + else current_certificate_pdf( + program, + config, + row, + request.build_absolute_uri("/").rstrip("/"), + ) ) - archive.writestr(f"certificate-{row.pk}.pdf", content) + archive.writestr(certificate_archive_name(row.name, names), content) output.seek(0) except Exception: output.close() @@ -466,16 +485,39 @@ def get(self, request, pk): output = tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + names = set() for row in ( - rows.filter(pdf__isnull=False).only("pk", "pdf").iterator(chunk_size=50) + rows.filter(pdf__isnull=False) + .only("pk", "name", "pdf") + .iterator(chunk_size=50) ): - archive.writestr(f"certificate-{row.pk}.pdf", bytes(row.pdf)) + archive.writestr( + certificate_archive_name(row.name, names), bytes(row.pdf) + ) output.seek(0) response = FileResponse(output, as_attachment=True, filename="certificates.zip") response["Cache-Control"] = "private, no-store" return response +def certificate_archive_name(name, used): + import re + from pathlib import PureWindowsPath + from unicodedata import normalize + + stem = re.sub(r'[\\/:*?"<>|\x00-\x1f\x7f]', "_", normalize("NFC", name)) + stem = stem[:120].strip(" .") or "Сертификат" + if PureWindowsPath(stem).is_reserved(): + stem = "Сертификат " + stem + filename = stem + ".pdf" + suffix = 2 + while filename.casefold() in used: + filename = f"{stem} ({suffix}).pdf" + suffix += 1 + used.add(filename.casefold()) + return filename + + class MyCertificatesView(ManagementBase): def get(self, request, pk): rows = ProgramCertificate.objects.filter( diff --git a/partner_programs/forms.py b/partner_programs/forms.py index 0a40a95c..012f6c24 100644 --- a/partner_programs/forms.py +++ b/partner_programs/forms.py @@ -4,6 +4,7 @@ from django.forms.models import BaseInlineFormSet from partner_programs.services.case_fields import case_field_has_values +from partner_programs.project_form import protected_project_field from partner_programs.services.field_values import ( FIELD_VALUES_FROZEN_MESSAGE, program_field_values_are_frozen, @@ -18,14 +19,20 @@ def clean(self): for form in self.deleted_forms: from partner_programs.solution_forms import answer_count - if case_field_has_values(form.instance) or ( - form.instance.pk - and form.instance.partner_program.solution_storage_mode == "solution" - and answer_count(form.instance) + field = form.instance + if not field.pk: + continue + if case_field_has_values(field) or ( + field.partner_program.solution_storage_mode == "solution" + and answer_count(field) ): raise ValidationError( "Нельзя удалить системное поле с выбранными кейсами." ) + if protected_project_field(field): + raise ValidationError( + "Стандартное поле или поле с ответами можно скрыть." + ) class ProgramFieldValueInlineFormSet(BaseInlineFormSet): diff --git a/partner_programs/migrations/0043_project_copy_form.py b/partner_programs/migrations/0043_project_copy_form.py new file mode 100644 index 00000000..4aef70f5 --- /dev/null +++ b/partner_programs/migrations/0043_project_copy_form.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.18 on 2026-11-08 14:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0042_partnerprogram_tag_optional"), + ("projects", "0035_interface_copy"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="project_sections", + field=models.JSONField( + blank=True, default=dict, verbose_name="Разделы проекта в программе" + ), + ), + migrations.AddField( + model_name="partnerprogramfield", + name="is_visible", + field=models.BooleanField(default=True, verbose_name="Показывать в форме"), + ), + migrations.AddField( + model_name="partnerprogramproject", + name="source_project", + field=models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_copies", + to="projects.project", + verbose_name="Исходный проект", + ), + ), + migrations.AddField( + model_name="partnerprogramproject", + name="submitted_form", + field=models.JSONField(blank=True, editable=False, null=True), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index d055a1fd..19f1df93 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -149,6 +149,9 @@ class PartnerProgram(models.Model): verbose_name="Максимум участников legacy-команды", help_text="NULL — без максимума. Ожидающие приглашения резервируют места.", ) + project_sections = models.JSONField( + default=dict, blank=True, verbose_name="Разделы проекта в программе" + ) city = models.TextField( verbose_name="Город", ) @@ -298,6 +301,17 @@ class PartnerProgram(models.Model): def clean(self): super().clean() self.validate_solution_template_change() + from partner_programs.project_form import PROJECT_SECTIONS + + if not isinstance(self.project_sections, dict) or any( + key not in PROJECT_SECTIONS or not isinstance(value, bool) + for key, value in self.project_sections.items() + ): + raise ValidationError( + { + "project_sections": "Укажите доступные разделы и значения true или false." + } + ) for field in ("legacy_team_min_size", "legacy_team_max_size"): value = getattr(self, field) if value is not None and value < 1: @@ -518,6 +532,16 @@ class PartnerProgramProject(models.Model): project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="program_links" ) + source_project = models.ForeignKey( + Project, + null=True, + blank=True, + editable=False, + on_delete=models.SET_NULL, + related_name="program_copies", + verbose_name="Исходный проект", + ) + submitted_form = models.JSONField(null=True, blank=True, editable=False) datetime_created = models.DateTimeField(auto_now_add=True) datetime_updated = models.DateTimeField(auto_now=True) submitted = models.BooleanField(default=False, verbose_name="Проект сдан") @@ -584,6 +608,17 @@ def save(self, *args, **kwargs): self.project = context.project self.partner_program = context.programs[self.partner_program_id] self.clean() + fields = kwargs.get("update_fields") + if ( + self.submitted + and self.submitted_form is None + and (fields is None or "submitted" in fields) + ): + from partner_programs.services.project_fields import form_snapshot + + self.submitted_form = form_snapshot(self) + if fields is not None: + kwargs["update_fields"] = {*fields, "submitted_form"} return super().save(*args, **kwargs) @transaction.atomic @@ -638,6 +673,7 @@ class PartnerProgramField(models.Model): label = models.CharField(max_length=256, verbose_name="Отображаемое название") field_type = models.CharField(max_length=20, choices=FIELD_TYPES) is_required = models.BooleanField(default=False) + is_visible = models.BooleanField(default=True, verbose_name="Показывать в форме") purpose = models.CharField(max_length=24, default="other") position = models.PositiveIntegerField(default=0) validation = models.JSONField(default=dict, blank=True) diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index 46005509..2e0ccc4c 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -1,5 +1,6 @@ from functools import wraps +from django.conf import settings from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.permissions import BasePermission, SAFE_METHODS @@ -10,9 +11,7 @@ def can_create_program(user) -> bool: return bool( - user - and user.is_authenticated - and user.has_perm(PROGRAM_CREATION_PERMISSION) + user and user.is_authenticated and user.has_perm(PROGRAM_CREATION_PERMISSION) ) @@ -27,6 +26,19 @@ def has_permission(self, request, view): return True +def active_programs(queryset): + if not settings.PROGRAM_SOLUTIONS_ENABLED: + return queryset.exclude(solution_storage_mode="solution") + return queryset + + +class SolutionFlowEnabled(BasePermission): + def has_permission(self, request, view): + if not settings.PROGRAM_SOLUTIONS_ENABLED: + raise NotFound() + return True + + class ProgramDraftVisibility(BasePermission): """One boundary for every program route, after DRF authentication.""" @@ -46,6 +58,15 @@ def has_permission(self, request, view): program = PartnerProgram.objects.filter(pk=pk).first() if pk else None # Reuse this read in read-only views, never for transactional writes. view.visibility_program = program + if ( + program + and program.solution_storage_mode == "solution" + and not settings.PROGRAM_SOLUTIONS_ENABLED + ): + setattr( + getattr(request, "_request", request), "_program_draft_response", True + ) + raise NotFound() if program is None or not program.draft: return True setattr(getattr(request, "_request", request), "_program_draft_response", True) diff --git a/partner_programs/program_launch_views.py b/partner_programs/program_launch_views.py index 8a0a33de..5b2c4ac1 100644 --- a/partner_programs/program_launch_views.py +++ b/partner_programs/program_launch_views.py @@ -6,7 +6,11 @@ from partner_programs.management_views import ManagementBase from partner_programs.models import PartnerProgram from partner_programs.pagination import ProgramAttentionPagination -from partner_programs.permissions import can_create_program, require_create_program +from partner_programs.permissions import ( + active_programs, + can_create_program, + require_create_program, +) from partner_programs.serializers.management import StrictFieldsMixin from partner_programs.services import program_launch as service from partner_programs.services.management import management_data @@ -42,7 +46,7 @@ class ManagedProgramsView(ManagementBase): def get(self, request): form = ManagedProgramsQuery(data=request.query_params) form.is_valid(raise_exception=True) - rows = PartnerProgram.objects.all() + rows = active_programs(PartnerProgram.objects.all()) if not (request.user.is_staff or request.user.is_superuser): rows = rows.filter(managers=request.user) if form.validated_data["search"]: diff --git a/partner_programs/project_form.py b/partner_programs/project_form.py new file mode 100644 index 00000000..89620198 --- /dev/null +++ b/partner_programs/project_form.py @@ -0,0 +1,32 @@ +"""The form of a Project copy belongs to its program.""" + +from partner_programs.constants import PROGRAM_CASE_FIELD_NAME + +PROJECT_SECTIONS = ("partners", "achievements", "vacancies", "program_data") +PROJECT_FIELDS = { + "project_description": "description", + "project_presentation": "presentation_address", +} + + +def project_sections(program): + return {key: program.project_sections.get(key, True) for key in PROJECT_SECTIONS} + + +def visible_project_fields(program): + fields = program.fields.filter(is_visible=True).order_by("pk") + if not project_sections(program)["program_data"]: + fields = fields.filter(name__in=(PROGRAM_CASE_FIELD_NAME, "project_description")) + return fields + + +def field_value(link, field, values): + attribute = PROJECT_FIELDS.get(field.name) + return getattr(link.project, attribute) if attribute else values.get(field.pk) + + +def protected_project_field(field): + return ( + field.name in PROJECT_FIELDS + or field.values.filter(value_text__regex=r"\S").exists() + ) diff --git a/partner_programs/serializers/attention.py b/partner_programs/serializers/attention.py index 1a80d5f3..86b9bd00 100644 --- a/partner_programs/serializers/attention.py +++ b/partner_programs/serializers/attention.py @@ -100,6 +100,10 @@ class ProgramAttentionProjectSerializer(serializers.Serializer): reason_label = serializers.SerializerMethodField() assignments_total = serializers.IntegerField(allow_null=True) assignments_completed = serializers.IntegerField(allow_null=True) + experts = serializers.SerializerMethodField() + + def get_experts(self, link): + return self.context.get("experts_by_project", {}).get(link.project.id, []) def get_reason(self, link): """Причина следует из общего статуса и реальных назначений, не из лимита.""" diff --git a/partner_programs/serializers/attention_actions.py b/partner_programs/serializers/attention_actions.py index 32c0600d..c052e861 100644 --- a/partner_programs/serializers/attention_actions.py +++ b/partner_programs/serializers/attention_actions.py @@ -40,3 +40,8 @@ class AttentionActionSerializer(serializers.Serializer): class AttentionConfirmationSerializer(serializers.Serializer): token = serializers.CharField(max_length=1000000) + excluded_recipient_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + max_length=MAX_SELECTION, + default=list, + ) diff --git a/partner_programs/serializers/fields.py b/partner_programs/serializers/fields.py index 35d67041..aac69a5c 100644 --- a/partner_programs/serializers/fields.py +++ b/partner_programs/serializers/fields.py @@ -51,6 +51,7 @@ def _get_validator(self, field: PartnerProgramField): "select": self._validate_select, "radio": self._validate_radio, "file": self._validate_file, + "url": self._validate_file, } try: return validators[field.field_type] diff --git a/partner_programs/services/attention_actions.py b/partner_programs/services/attention_actions.py index 46e5f721..35076b69 100644 --- a/partner_programs/services/attention_actions.py +++ b/partner_programs/services/attention_actions.py @@ -193,19 +193,25 @@ def preview_reminder(program, category, actor, payload): if available else None ) + recipients = [] + for target in available[:20] if category == SOLUTION_AWAITING else available: + user = users[target["user_id"]] + recipient = {"user_id": user.pk, "full_name": person_name(user)} + if category != SOLUTION_AWAITING: + recipient.update(email=user.email, avatar=user.avatar) + recipients.append(recipient) return { "token": token, "selected_count": len(rows), "recipient_count": len(available), - "without_assignments_count": len(rows) - len(assigned_sources) - if category in ("projects-awaiting-evaluation", SOLUTION_AWAITING) - else 0, + "without_assignments_count": ( + len(rows) - len(assigned_sources) + if category in ("projects-awaiting-evaluation", SOLUTION_AWAITING) + else 0 + ), "unavailable_count": len(targets) - len(available), "preview_text": preview_text, - "recipients": [ - {"user_id": t["user_id"], "full_name": person_name(users[t["user_id"]])} - for t in available[:20] - ], + "recipients": recipients, "expires_in": PREVIEW_TTL, } @@ -254,7 +260,7 @@ def fresh_targets(program, category, ids, snapshot): return rows, result -def confirm_reminder(program, category, actor, token): +def confirm_reminder(program, category, actor, token, *, excluded_recipient_ids=()): try: data = signing.loads(token, salt=SALT, max_age=PREVIEW_TTL) except signing.BadSignature: @@ -269,6 +275,12 @@ def confirm_reminder(program, category, actor, token): raise ValidationError( {"detail": "Предпросмотр относится к другому списку или пользователю."} ) + excluded = set(excluded_recipient_ids) + if excluded - {target["user_id"] for target in data["targets"]}: + raise ValidationError({"detail": "Получатель не входит в список напоминаний."}) + snapshot = [target for target in data["targets"] if target["user_id"] not in excluded] + if not snapshot: + raise ValidationError({"detail": "Оставьте хотя бы одного получателя."}) with transaction.atomic(): # Serialize confirmations for the same program; token UUID is the durable # idempotency key, including when the client loses the HTTP response. @@ -278,16 +290,14 @@ def confirm_reminder(program, category, actor, token): ).first() if existing: return existing.result - rows, targets = fresh_targets( - program, category, data["source_ids"], data["targets"] - ) + rows, targets = fresh_targets(program, category, data["source_ids"], snapshot) users = CustomUser.objects.in_bulk([t["user_id"] for t in targets]) targets = [ t for t in targets if users.get(t["user_id"]) and users[t["user_id"]].email ] result = { "created_count": len(targets), - "skipped_count": len(data["targets"]) - len(targets), + "skipped_count": len(snapshot) - len(targets), } batch = ProgramAttentionReminderBatch.objects.create( id=data["request_id"], diff --git a/partner_programs/services/case_fields.py b/partner_programs/services/case_fields.py index e533ef06..562aad50 100644 --- a/partner_programs/services/case_fields.py +++ b/partner_programs/services/case_fields.py @@ -49,6 +49,8 @@ def validate_program_case_configuration(field): errors["field_type"] = "Системный кейс должен быть полем select." if not field.is_required: errors["is_required"] = "Кейс обязателен перед сдачей проекта." + if not field.is_visible: + errors["is_visible"] = "Кейс должен быть доступен в форме сдачи." if not field.show_filter: errors["show_filter"] = "Системный кейс должен быть доступен для фильтрации." options = ( @@ -64,9 +66,9 @@ def validate_program_case_configuration(field): errors["options"] = "Укажите хотя бы один кейс без пустых вариантов." options = [] elif len({value.strip().casefold() for value in options}) != len(options): - errors[ - "options" - ] = "Варианты кейсов не должны повторяться (без учета регистра и внешних пробелов)." + errors["options"] = ( + "Варианты кейсов не должны повторяться (без учета регистра и внешних пробелов)." + ) if field.pk: used = set(field.values.values_list("value_text", flat=True).distinct()) if used - set(options): @@ -89,6 +91,14 @@ def validate_case_before_submission(program_project, *, for_update=True): """Lock config inside the caller's transaction; validate this link only.""" field = get_program_case_field(program_project.partner_program, for_update=for_update) if field is None: + program = program_project.partner_program + if program.is_competitive and program.program_format in ( + "case_championship", + "hackathon", + ): + raise ValidationError( + "Организатор еще не настроил кейсы или треки программы." + ) return value = ( program_project.field_values.filter(field=field) diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index 16e9754a..5690c933 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -29,6 +29,14 @@ logger = logging.getLogger(__name__) CERTIFICATE_FONTS = { "mont": ("Mont", "Mont-Regular.ttf", "Mont-SemiBold.ttf", 600), + "mont-regular": ( + "Certificate Mont Regular", + "Mont-Regular.ttf", + "Mont-Regular.ttf", + 400, + ), + "mont-light": ("Certificate Mont Light", "Mont-Light.ttf", "Mont-Light.ttf", 400), + "mont-bold": ("Certificate Mont Bold", "Mont-Bold.ttf", "Mont-Bold.ttf", 400), "pt-serif": ("Certificate PT Serif", "PTSerif-Regular.ttf", "PTSerif-Bold.ttf", 700), "lobster": ("Certificate Lobster", "Lobster-Regular.ttf", "Lobster-Regular.ttf", 400), } @@ -175,6 +183,20 @@ def assets_for(program, draft): return result +def uploaded_fonts(program_id, draft): + keys = {box.get("font_family", "") for box in draft["layout"].values()} + ids = {int(key.removeprefix("custom-")) for key in keys if key.startswith("custom-")} + if not ids: + return {} + rows = ProgramCertificateAsset.objects.filter( + program_id=program_id, kind="font", pk__in=ids + ) + fonts = {f"custom-{asset.pk}": asset for asset in rows} + if len(fonts) != len(ids): + raise ValidationError("Шрифт не найден в этой программе.") + return fonts + + def state(program): config = config_for(program) batch = ( @@ -186,6 +208,14 @@ def state(program): return { "revision": config.revision, "draft": normalized_draft(config.draft), + "fonts": [ + asset_data(asset) + for asset in ProgramCertificateAsset.objects.filter( + program=program, kind="font" + ) + .defer("content") + .order_by("pk") + ], "assets": { key: asset_data(value) for key, value in assets_for(program, config.draft).items() @@ -193,14 +223,16 @@ def state(program): "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, + "batch": ( + { + "id": batch.pk, + "active": batch.active, + "issue_at": batch.issue_at, + "initialized_at": batch.initialized_at, + } + if batch + else None + ), } @@ -255,6 +287,8 @@ def manual_batch(program, draft, public_base_url, action): def upload_asset(program, kind, upload): + if kind == "font": + return upload_font(program, upload) if kind not in ("background", "signature") or not upload: raise ValidationError("Выберите фон или подпись и файл.") if upload.size > 10 * 1024 * 1024: @@ -298,6 +332,33 @@ def upload_asset(program, kind, upload): ) +@transaction.atomic +def upload_font(program, upload): + if not upload or upload.size > 2 * 1024 * 1024: + raise ValidationError("Выберите файл шрифта TTF или OTF размером до 2 МБ.") + name = Path(upload.name).name[:255] + content = upload.read() + try: + if (Path(name).suffix.lower(), content[:4]) not in ( + (".ttf", b"\x00\x01\x00\x00"), + (".otf", b"OTTO"), + ): + raise ValueError() + font = ImageFont.truetype(io.BytesIO(content), 40) + font.getlength("Имя участника") + except (OSError, ValueError) as error: + raise ValidationError( + "Не удалось прочитать шрифт. Загрузите файл TTF или OTF." + ) from error + type(program).objects.select_for_update().get(pk=program.pk) + fonts = ProgramCertificateAsset.objects.filter(program=program, kind="font") + if fonts.count() >= 24: + raise ValidationError("Можно загрузить до 24 шрифтов на программу.") + return fonts.create( + program=program, kind="font", name=name, content=content, width=0, height=0 + ) + + def validate_draft(program, draft, previous=None): """The API owns allowed geometry and substitutions, not arbitrary HTML/CSS.""" from rest_framework import serializers @@ -308,8 +369,9 @@ class Element(serializers.Serializer): 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) - font_family = serializers.ChoiceField( - choices=list(CERTIFICATE_FONTS), required=False + font_family = serializers.RegexField( + r"^(?:mont(?:-regular|-light|-bold)?|pt-serif|lobster|custom-[1-9][0-9]{0,17})\Z", + required=False, ) class Draft(serializers.Serializer): @@ -384,6 +446,7 @@ class Draft(serializers.Serializer): "Доступные подстановки: {name}, {program}, {date}, {number}." ) assets_for(program, data) + uploaded_fonts(program.pk, data) return normalized_draft(dict(data)) @@ -394,7 +457,9 @@ def certificate_font_key(box): def certificate_font_path(key, bold): filename = CERTIFICATE_FONTS[key][2 if bold else 1] - directory = "assets/users/user_CV" if key == "mont" else "assets/certificates/fonts" + directory = ( + "assets/users/user_CV" if key.startswith("mont") else "assets/certificates/fonts" + ) return Path(settings.BASE_DIR) / directory / filename @@ -407,9 +472,10 @@ def page_dimensions(orientation="landscape"): return (210, 297) if orientation == "portrait" else (297, 210) -def fit_certificate_text(value, box, bold=False, orientation="landscape"): +def fit_certificate_text(value, box, bold=False, orientation="landscape", font=None): """Подгоняем реальные строки выбранного шрифта без обрезки ФИО.""" - font = certificate_font(bold, certificate_font_key(box)) + if font is None: + font = certificate_font(bold, certificate_font_key(box)) page_width, page_height = ( (793.7, 1122) if orientation == "portrait" else (1122, 793.7) ) @@ -451,16 +517,28 @@ def fits(text): return size, lines_at(size) -def render_pdf(snapshot, assets, name, program_name, number, issued_at): +def render_pdf( + snapshot, assets, name, program_name, number, issued_at, *, program_id=None +): from weasyprint import HTML from weasyprint.text.fonts import FontConfiguration from weasyprint.urls import URLFetcher, URLFetcherResponse orientation = snapshot.get("orientation", "landscape") page_width, page_height = page_dimensions(orientation) + fonts = uploaded_fonts(program_id, snapshot) + custom_faces = { + key: ImageFont.truetype(io.BytesIO(bytes(asset.content)), 1000) + for key, asset in fonts.items() + } class CertificateURLFetcher(URLFetcher): def fetch(self, url, headers=None): + custom = fonts.get(url.removeprefix("certificate-font:")) + if custom and url.startswith("certificate-font:"): + return URLFetcherResponse( + url, bytes(custom.content), {"Content-Type": "font/sfnt"} + ) for key in CERTIFICATE_FONTS: for bold, weight in ((False, "regular"), (True, "bold")): if url == f"certificate-font:{key}:{weight}": @@ -505,11 +583,17 @@ def src(asset): ) else: value = values[key] - family = certificate_font_key(box) - family_name, _, _, bold_weight = CERTIFICATE_FONTS[family] - weight = bold_weight if key in ("name", "title") else 400 + custom_key = box.get("font_family") + custom_font = custom_faces.get(custom_key) + bold = key in ("name", "title") + if custom_font: + family_name, weight = custom_key, 400 + else: + family = certificate_font_key(box) + family_name, _, _, bold_weight = CERTIFICATE_FONTS[family] + weight = bold_weight if bold else 400 size, lines = fit_certificate_text( - value, box, key in ("name", "title"), orientation + value, box, bold, orientation, font=custom_font ) content = "
".join(escape(line) for line in lines) elements.append( @@ -518,6 +602,10 @@ def src(asset): ) html = ( '