import io
from datetime import datetime
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
from app.core.s3 import s3_service
from app.models.interview_report import InterviewReport, RecommendationType
class PDFReportService:
"""Сервис для генерации PDF отчетов по интервью"""
def __init__(self):
self.styles = getSampleStyleSheet()
self._setup_custom_styles()
def _setup_custom_styles(self):
"""Настройка кастомных стилей для документа"""
# Заголовок отчета
self.styles.add(
ParagraphStyle(
name="ReportTitle",
parent=self.styles["Title"],
fontSize=18,
spaceAfter=30,
alignment=TA_CENTER,
textColor=colors.HexColor("#2E3440"),
fontName="Helvetica-Bold",
)
)
# Заголовки секций
self.styles.add(
ParagraphStyle(
name="SectionHeader",
parent=self.styles["Heading1"],
fontSize=14,
spaceAfter=12,
spaceBefore=20,
textColor=colors.HexColor("#5E81AC"),
fontName="Helvetica-Bold",
)
)
# Подзаголовки
self.styles.add(
ParagraphStyle(
name="SubHeader",
parent=self.styles["Heading2"],
fontSize=12,
spaceAfter=8,
spaceBefore=15,
textColor=colors.HexColor("#81A1C1"),
fontName="Helvetica-Bold",
)
)
# Обычный текст
self.styles.add(
ParagraphStyle(
name="BodyText",
parent=self.styles["Normal"],
fontSize=10,
spaceAfter=6,
alignment=TA_JUSTIFY,
textColor=colors.HexColor("#2E3440"),
)
)
# Стиль для метрик
self.styles.add(
ParagraphStyle(
name="MetricValue",
parent=self.styles["Normal"],
fontSize=12,
alignment=TA_CENTER,
textColor=colors.HexColor("#5E81AC"),
fontName="Helvetica-Bold",
)
)
async def generate_interview_report_pdf(
self, report: InterviewReport, candidate_name: str, position: str
) -> bytes:
"""
Генерирует PDF отчет по интервью
Args:
report: Модель отчета из БД
candidate_name: Имя кандидата
position: Название позиции
Returns:
bytes: PDF файл в виде байтов
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=72,
)
# Собираем элементы документа
story = []
# Заголовок отчета
story.append(
Paragraph(
f"Отчет по собеседованию
{candidate_name}",
self.styles["ReportTitle"],
)
)
# Основная информация
story.append(Paragraph("Основная информация", self.styles["SectionHeader"]))
basic_info = [
["Кандидат:", candidate_name],
["Позиция:", position],
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
["Общий балл:", f"{report.overall_score}/100"],
["Рекомендация:", self._format_recommendation(report.recommendation)],
]
basic_table = Table(basic_info, colWidths=[2 * inch, 4 * inch])
basic_table.setStyle(
TableStyle(
[
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.append(basic_table)
story.append(Spacer(1, 20))
# Оценки по критериям
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
criteria_data = [
["Критерий", "Балл", "Обоснование", "Риски"],
[
"Технические навыки",
f"{report.technical_skills_score}/100",
report.technical_skills_justification or "—",
report.technical_skills_concerns or "—",
],
[
"Релевантность опыта",
f"{report.experience_relevance_score}/100",
report.experience_relevance_justification or "—",
report.experience_relevance_concerns or "—",
],
[
"Коммуникация",
f"{report.communication_score}/100",
report.communication_justification or "—",
report.communication_concerns or "—",
],
[
"Решение задач",
f"{report.problem_solving_score}/100",
report.problem_solving_justification or "—",
report.problem_solving_concerns or "—",
],
[
"Культурное соответствие",
f"{report.cultural_fit_score}/100",
report.cultural_fit_justification or "—",
report.cultural_fit_concerns or "—",
],
]
criteria_table = Table(
criteria_data, colWidths=[2 * inch, 0.8 * inch, 2.2 * inch, 1.8 * inch]
)
criteria_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (1, 1), (1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
]
)
)
# Цветовое кодирование баллов
for i in range(1, 6): # строки с баллами
score_cell = (1, i)
if hasattr(
report,
[
"technical_skills_score",
"experience_relevance_score",
"communication_score",
"problem_solving_score",
"cultural_fit_score",
][i - 1],
):
score = getattr(
report,
[
"technical_skills_score",
"experience_relevance_score",
"communication_score",
"problem_solving_score",
"cultural_fit_score",
][i - 1],
)
if score >= 80:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#A3BE8C"),
)
]
)
)
elif score >= 60:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#EBCB8B"),
)
]
)
)
else:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#BF616A"),
)
]
)
)
story.append(criteria_table)
story.append(Spacer(1, 20))
# Сильные и слабые стороны
if report.strengths or report.weaknesses:
story.append(Paragraph("Анализ кандидата", self.styles["SectionHeader"]))
if report.strengths:
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
for strength in report.strengths:
story.append(Paragraph(f"• {strength}", self.styles["BodyText"]))
story.append(Spacer(1, 10))
if report.weaknesses:
story.append(
Paragraph("Области для развития:", self.styles["SubHeader"])
)
for weakness in report.weaknesses:
story.append(Paragraph(f"• {weakness}", self.styles["BodyText"]))
story.append(Spacer(1, 10))
# Красные флаги
if report.red_flags:
story.append(Paragraph("Важные риски:", self.styles["SubHeader"]))
for red_flag in report.red_flags:
story.append(
Paragraph(
f"⚠ {red_flag}",
ParagraphStyle(
name="RedFlag",
parent=self.styles["BodyText"],
textColor=colors.HexColor("#BF616A"),
),
)
)
story.append(Spacer(1, 15))
# Рекомендации и следующие шаги
if report.next_steps:
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
story.append(Paragraph(report.next_steps, self.styles["BodyText"]))
story.append(Spacer(1, 15))
# Метрики интервью
if any(
[
report.interview_duration_minutes,
report.dialogue_messages_count,
report.questions_quality_score,
]
):
story.append(Paragraph("Метрики интервью", self.styles["SectionHeader"]))
metrics = []
if report.interview_duration_minutes:
metrics.append(
["Длительность:", f"{report.interview_duration_minutes} мин"]
)
if report.dialogue_messages_count:
metrics.append(
["Сообщений в диалоге:", str(report.dialogue_messages_count)]
)
if report.questions_quality_score:
metrics.append(
["Качество ответов:", f"{report.questions_quality_score:.1f}/10"]
)
metrics_table = Table(metrics, colWidths=[2 * inch, 2 * inch])
metrics_table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.append(metrics_table)
# Подпись
story.append(Spacer(1, 30))
story.append(
Paragraph(
f"Отчет сгенерирован автоматически • {datetime.now().strftime('%d.%m.%Y %H:%M')}",
ParagraphStyle(
name="Footer",
parent=self.styles["Normal"],
fontSize=8,
alignment=TA_CENTER,
textColor=colors.HexColor("#4C566A"),
),
)
)
# Генерируем PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
def _format_recommendation(self, recommendation: RecommendationType) -> str:
"""Форматирует рекомендацию для отображения"""
recommendation_map = {
RecommendationType.STRONGLY_RECOMMEND: "✅ Настоятельно рекомендуем",
RecommendationType.RECOMMEND: "👍 Рекомендуем",
RecommendationType.CONSIDER: "🤔 Рассмотреть кандидатуру",
RecommendationType.REJECT: "❌ Не рекомендуем",
}
return recommendation_map.get(recommendation, str(recommendation))
async def generate_and_upload_pdf(
self, report: InterviewReport, candidate_name: str, position: str
) -> str | None:
"""
Генерирует PDF отчет и загружает его в S3
Args:
report: Модель отчета из БД
candidate_name: Имя кандидата
position: Название позиции
Returns:
str | None: URL файла в S3 или None при ошибке
"""
try:
# Генерируем PDF
pdf_bytes = await self.generate_interview_report_pdf(
report, candidate_name, position
)
# Формируем имя файла
safe_name = "".join(
c for c in candidate_name if c.isalnum() or c in (" ", "-", "_")
).strip()
safe_name = safe_name.replace(" ", "_")
filename = f"interview_report_{safe_name}_{report.id}.pdf"
# Загружаем в S3
file_url = await s3_service.upload_file(
file_content=pdf_bytes,
file_name=filename,
content_type="application/pdf",
)
return file_url
except Exception as e:
print(f"Error generating and uploading PDF report: {e}")
return None
# Экземпляр сервиса
pdf_report_service = PDFReportService()