Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
for number, item in enumerate(report.findings, 1):
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
for number, item in enumerate(report.competencies, 1):
@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
("Нарушенных метрик", report.failed_metrics),
("Результат", "зачёт" if report.passed else "не зачтено"),
("Автоматическая оценка", report.score_auto),
("Итоговая оценка", report.score_final),
):
@ -155,7 +167,7 @@ def to_pdf(report: SessionReport) -> bytes:
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes:
document.build(story, onFirstPage=footer, onLaterPages=footer)
return output.getvalue()
def certificate_pdf(
*, trainee_name: str, trainee_id: object, group_name: str | None,
attempts: int, average_score: float, issued_at: object,
) -> bytes:
"""Одностраничный сертификат по фактически сохранённым результатам."""
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
regular_path, bold_path = _font_paths()
regular_name = "LCTCertificateRegular"
bold_name = "LCTCertificateBold"
if regular_name not in pdfmetrics.getRegisteredFontNames():
regular = TTFont(regular_name, str(regular_path))
if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"):
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
pdfmetrics.registerFont(regular)
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
title = ParagraphStyle(
"certificate-title", fontName=bold_name, fontSize=28, leading=34,
alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20,
)
body = ParagraphStyle(
"certificate-body", fontName=regular_name, fontSize=14, leading=21,
alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10,
)
name_style = ParagraphStyle(
"certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28,
textColor=colors.HexColor("#a24816"), spaceAfter=18,
)
small = ParagraphStyle(
"certificate-small", parent=body, fontSize=9, leading=13,
textColor=colors.HexColor("#5d6873"),
)
safe_name = escape(trainee_name)
safe_group = escape(group_name or "без группы")
safe_date = escape(str(issued_at))
story = [
Spacer(1, 34),
Paragraph("СЕРТИФИКАТ", title),
Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body),
Spacer(1, 12),
Paragraph(safe_name, name_style),
Paragraph(
f"Выполнено занятий: <b>{attempts}</b> · средняя итоговая оценка: "
f"<b>{average_score:.1f} из 100</b>", body,
),
Paragraph(f"Учебная группа: {safe_group}", body),
Spacer(1, 28),
Paragraph(f"Дата формирования: {safe_date}", small),
Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small),
Paragraph(
"Документ сформирован локально по сохранённым результатам и не заменяет "
"квалификационный документ установленного образца.", small,
),
]
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=landscape(A4), leftMargin=60, rightMargin=60,
topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения",
author="Учебный симулятор 112",
)
document.build(story)
return output.getvalue()