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
Binary file added assets/users/user_CV/Mont-Bold.ttf
Binary file not shown.
Binary file added assets/users/user_CV/Mont-Light.ttf
Binary file not shown.
3 changes: 3 additions & 0 deletions docker-compose.dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
21 changes: 16 additions & 5 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = (
Expand All @@ -414,6 +424,7 @@ def get_deleted_objects(self, objs, request):
"label",
"field_type",
"is_required",
"is_visible",
"show_filter",
)
list_filter = ("partner_program",)
Expand Down
122 changes: 122 additions & 0 deletions partner_programs/admin_certificate_fonts.py
Original file line number Diff line number Diff line change
@@ -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(
"<int:object_id>/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]
),
},
)
8 changes: 7 additions & 1 deletion partner_programs/attention_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
56 changes: 49 additions & 7 deletions partner_programs/certificate_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -93,6 +94,7 @@ def post(self, request, pk):
program.name,
"ПРОБНЫЙ",
timezone.localtime(),
program_id=program.pk,
)
return pdf_response(content, "certificate-preview")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -430,25 +433,41 @@ 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(
"Дождитесь завершения обработки выбранных сертификатов."
)
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()
Expand All @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions partner_programs/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down
Loading
Loading