"""Экспорт разбора занятия без обращения к внешним сервисам.
CSV - плоский журнал с пометкой раздела. PDF - читаемый протокол с переносом
длинных реплик и автоматическим разбиением на страницы.
"""
import csv
import io
import os
from html import escape
from pathlib import Path
from app.domain.events import SessionReport
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str:
"""Excel/LibreOffice не должны исполнять текст курсанта как формулу."""
if value is None:
return ""
result = str(value)
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
def _finding_origin(item: Finding) -> str:
if item.source is FindingSource.INSTRUCTOR:
return f"; добавлена преподавателем {item.author or ''}".rstrip()
return ""
def _finding_decision(item: Finding) -> str:
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
return "".join(
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
f"преподавателем {review.author}: {review.reason}"
for review in item.reviews
)
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
writer = csv.writer(output, lineterminator="\r\n")
writer.writerow(["Раздел", "№", "Поле", "Значение", "Дополнительно"])
def row(section: str, index: object, field: str, value: object, detail: object = "") -> None:
writer.writerow([_cell(part) for part in (section, index, field, value, detail)])
row("Занятие", "", "ID", report.session_id)
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит открытия карточки ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds
if report.criteria.dds_report_reaction_enabled else "не применяется")
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_reviewed)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
for number, item in enumerate(report.metrics, 1):
credit = f"; оценочный вклад: {item.credit:.0%}" if item.credit is not None else ""
row("Метрики", number, item.title, item.fact,
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {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 ''}"
f"{_finding_origin(item)}{_finding_decision(item)}")
for number, item in enumerate(report.competencies, 1):
row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1):
row("Транскрипт", number, item.speaker.value, item.text, item.at.isoformat())
for number, item in enumerate(report.reference_questions, 1):
row("Эталонные вопросы", number, item.checklist_id, item.question)
for number, item in enumerate(report.missed_checklist, 1):
row("Пропущенные вопросы", number, "Пункт", item)
for number, item in enumerate(report.hints_used, 1):
row("Подсказки", number, item.checklist_id, item.question, item.at.isoformat())
if report.self_assessment:
row("Самооценка", "", "Пропущено по мнению курсанта", ", ".join(report.self_assessment.missed))
row("Самооценка", "", "Комментарий", report.self_assessment.comment)
if report.self_assessment_diff:
for label, values in (
("Заметил", report.self_assessment_diff.noticed),
("Не заметил", report.self_assessment_diff.unnoticed),
("Отметил ошибочно", report.self_assessment_diff.overcautious),
):
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
for card_number, card in enumerate(report.card_results, 1):
for action in card.actions:
if action.get("type") == "card.error_report_112":
row("Сообщение в 112", card_number, "Ошибка карточки",
action.get("text", ""), action.get("at", ""))
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
"""Находить TTF с кириллицей явно, без молчаливой подмены Helvetica."""
custom = os.environ.get("LCT_PDF_FONT")
if custom:
path = Path(custom)
if not path.is_file():
raise RuntimeError(f"LCT_PDF_FONT не найден: {path}")
return path, None
# DejaVu лежит в пакете: демо без Docker идёт на хосте, где системного шрифта
# может не быть, а экспорт PDF должен работать офлайн без доустановки.
bundled = Path(__file__).resolve().parent.parent / "assets" / "fonts"
candidates = (
(str(bundled / "DejaVuSans.ttf"), str(bundled / "DejaVuSans-Bold.ttf")),
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"),
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
)
for regular, bold in candidates:
if Path(regular).is_file():
return Path(regular), Path(bold) if Path(bold).is_file() else None
raise RuntimeError("Не найден TTF-шрифт с кириллицей; установите DejaVu Sans или задайте LCT_PDF_FONT")
def to_pdf(report: SessionReport) -> bytes:
"""Собрать многостраничный PDF с кириллицей и переносом длинных текстов."""
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer
regular_path, bold_path = _font_paths()
regular_name = "LCTReportRegular"
bold_name = "LCTReportBold"
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)))
pdfmetrics.registerFontFamily(regular_name, normal=regular_name, bold=bold_name)
base = ParagraphStyle(
"body", fontName=regular_name, fontSize=9, leading=13,
textColor=colors.HexColor("#18202a"), spaceAfter=5,
allowWidows=0, allowOrphans=0, splitLongWords=1,
)
title_style = ParagraphStyle(
"title", parent=base, fontName=bold_name, fontSize=17, leading=22,
textColor=colors.HexColor("#193b56"), spaceAfter=10,
)
heading = ParagraphStyle(
"heading", parent=base, fontName=bold_name, fontSize=12, leading=17,
textColor=colors.HexColor("#193b56"), spaceBefore=13, spaceAfter=5,
keepWithNext=True,
)
muted = ParagraphStyle("muted", parent=base, fontSize=8, textColor=colors.HexColor("#5d6873"))
def p(value: object, style=base):
# XML-экранирование необходимо: реплики и пометки вводит человек.
safe = escape("" if value is None else str(value)).replace("\n", "
")
return Paragraph(safe or "-", style)
story = [p("Отчёт по учебному занятию", title_style)]
for label, value in (
("Идентификатор", report.session_id),
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит открытия карточки ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады",
f"{report.criteria.dds_report_reaction_limit_seconds} с"
if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", 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),
):
story.append(Paragraph(f"{escape(label)}: {escape(str(value))}", base))
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
if report.score_reviewed is not None:
story.append(Paragraph(f"После разбора отметок: {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"Оценка изменена: {escape(report.overridden_by)}", base))
if report.override_comment:
story.append(p(f"Причина изменения: {report.override_comment}"))
story.extend([Spacer(1, 7), HRFlowable(width="100%", color=colors.HexColor("#aeb8c2"))])
def section(label: str):
story.append(p(label, heading))
section("Метрики")
if not report.metrics:
story.append(p("Нет данных", muted))
for item in report.metrics:
credit = f", оценочный вклад {item.credit:.0%}" if item.credit is not None else ""
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} "
f"(вес {item.weight:g}{credit})"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
section("Выявленные ошибки")
if not report.findings:
story.append(p("Ошибок не выявлено", muted))
for item in report.findings:
story.append(p(f"{item.code.value} - {item.summary}"))
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
f"Источник: {item.ref or 'не указан'}"
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
section("Компетенции")
for item in report.competencies:
story.append(p(f"{item.competency}: {item.value:g}"))
if not report.competencies:
story.append(p("Нет данных", muted))
section("Транскрипт")
if not report.transcript:
story.append(p("Реплик нет", muted))
for item in report.transcript:
story.append(p(f"{item.at.isoformat()} - {item.speaker.value}", muted))
story.append(p(item.text))
section("Эталонные вопросы")
for item in report.reference_questions:
story.append(p(f"{item.checklist_id}: {item.question}"))
if not report.reference_questions:
story.append(p("Нет данных", muted))
if report.missed_checklist:
section("Пропущенные пункты")
for item in report.missed_checklist:
story.append(p(item))
if report.hints_used:
section("Использованные подсказки")
for item in report.hints_used:
story.append(p(f"{item.at.isoformat()} - {item.checklist_id}: {item.question}"))
if report.self_assessment:
section("Самооценка")
story.append(p("Пропущено по мнению курсанта: " + ", ".join(report.self_assessment.missed)))
story.append(p("Комментарий: " + report.self_assessment.comment))
if report.self_assessment_diff:
for label, items in (
("Заметил", report.self_assessment_diff.noticed),
("Не заметил", report.self_assessment_diff.unnoticed),
("Отметил ошибочно", report.self_assessment_diff.overcautious),
):
story.append(p(f"{label}: {', '.join(items) or '-'}"))
if report.notes:
section("Пометки преподавателя")
for item in report.notes:
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
messages_112 = [
(number, action)
for number, card in enumerate(report.card_results, 1)
for action in card.actions
if action.get("type") == "card.error_report_112"
]
if messages_112:
section("Сообщения в 112 об ошибках карточек")
for number, action in messages_112:
story.append(p(f"Карточка {number}, {action.get('at', '')}: {action.get('text', '')}"))
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=A4, leftMargin=42, rightMargin=42,
topMargin=45, bottomMargin=44,
title="Отчёт по учебному занятию", author="Учебный симулятор 112",
)
def footer(canvas, doc):
canvas.saveState()
canvas.setFont(regular_name, 8)
canvas.setFillColor(colors.HexColor("#5d6873"))
canvas.drawString(42, 25, "Учебный симулятор 112")
canvas.drawRightString(A4[0] - 42, 25, f"Стр. {doc.page}")
canvas.restoreState()
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"Выполнено занятий: {attempts} · средняя итоговая оценка: "
f"{average_score:.1f} из 100", 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()