add pdf report generating
This commit is contained in:
parent
d397704bd5
commit
8d449af338
@ -18,19 +18,24 @@ class S3Service:
|
|||||||
self.bucket_name = settings.s3_bucket_name
|
self.bucket_name = settings.s3_bucket_name
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self, file_content: bytes, file_name: str, content_type: str
|
self, file_content: bytes, file_name: str, content_type: str, public: bool = False
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
try:
|
try:
|
||||||
file_key = f"{uuid.uuid4()}_{file_name}"
|
file_key = f"{uuid.uuid4()}_{file_name}"
|
||||||
|
|
||||||
self.s3_client.put_object(
|
put_object_kwargs = {
|
||||||
Bucket=self.bucket_name,
|
"Bucket": self.bucket_name,
|
||||||
Key=file_key,
|
"Key": file_key,
|
||||||
Body=file_content,
|
"Body": file_content,
|
||||||
ContentType=content_type,
|
"ContentType": content_type,
|
||||||
)
|
}
|
||||||
|
|
||||||
|
if public:
|
||||||
|
put_object_kwargs["ACL"] = "public-read"
|
||||||
|
|
||||||
|
self.s3_client.put_object(**put_object_kwargs)
|
||||||
|
|
||||||
file_url = f"{settings.s3_endpoint_url}/{self.bucket_name}/{file_key}"
|
file_url = f"https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/{file_key}"
|
||||||
return file_url
|
return file_url
|
||||||
|
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
|
@ -73,3 +73,4 @@ class LiveKitTokenResponse(SQLModel):
|
|||||||
token: str
|
token: str
|
||||||
room_name: str
|
room_name: str
|
||||||
server_url: str
|
server_url: str
|
||||||
|
session_id: int
|
||||||
|
@ -4,6 +4,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.repositories.resume_repository import ResumeRepository
|
from app.repositories.resume_repository import ResumeRepository
|
||||||
|
from app.services.pdf_report_service import pdf_report_service
|
||||||
from celery_worker.interview_analysis_task import (
|
from celery_worker.interview_analysis_task import (
|
||||||
analyze_multiple_candidates,
|
analyze_multiple_candidates,
|
||||||
generate_interview_report,
|
generate_interview_report,
|
||||||
@ -44,6 +45,16 @@ class CandidateRanking(BaseModel):
|
|||||||
position: str
|
position: str
|
||||||
|
|
||||||
|
|
||||||
|
class PDFGenerationResponse(BaseModel):
|
||||||
|
"""Ответ генерации PDF отчета"""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
resume_id: int
|
||||||
|
candidate_name: str
|
||||||
|
pdf_url: str | None = None
|
||||||
|
status: str # "generated", "exists", "failed"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/interview-report/{resume_id}", response_model=AnalysisResponse)
|
@router.post("/interview-report/{resume_id}", response_model=AnalysisResponse)
|
||||||
async def start_interview_analysis(
|
async def start_interview_analysis(
|
||||||
resume_id: int,
|
resume_id: int,
|
||||||
@ -264,7 +275,7 @@ async def get_pdf_report(
|
|||||||
.where(InterviewSession.resume_id == resume_id)
|
.where(InterviewSession.resume_id == resume_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await session.exec(statement)
|
result = await session.execute(statement)
|
||||||
report_session = result.first()
|
report_session = result.first()
|
||||||
|
|
||||||
if not report_session:
|
if not report_session:
|
||||||
@ -289,6 +300,105 @@ async def get_pdf_report(
|
|||||||
return RedirectResponse(url=report.pdf_report_url, status_code=302)
|
return RedirectResponse(url=report.pdf_report_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse)
|
||||||
|
async def generate_pdf_report(
|
||||||
|
resume_id: int,
|
||||||
|
session=Depends(get_session),
|
||||||
|
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Генерирует PDF отчет по интервью
|
||||||
|
|
||||||
|
Проверяет наличие отчета в базе данных и генерирует PDF файл.
|
||||||
|
Если PDF уже существует, возвращает существующий URL.
|
||||||
|
"""
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.models.interview import InterviewSession
|
||||||
|
from app.models.interview_report import InterviewReport
|
||||||
|
|
||||||
|
# Проверяем, существует ли резюме
|
||||||
|
resume = await resume_repo.get_by_id(resume_id)
|
||||||
|
if not resume:
|
||||||
|
raise HTTPException(status_code=404, detail="Resume not found")
|
||||||
|
|
||||||
|
# Ищем отчет интервью
|
||||||
|
statement = (
|
||||||
|
select(InterviewReport, InterviewSession)
|
||||||
|
.join(
|
||||||
|
InterviewSession,
|
||||||
|
InterviewReport.interview_session_id == InterviewSession.id,
|
||||||
|
)
|
||||||
|
.where(InterviewSession.resume_id == resume_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(statement)
|
||||||
|
report_session = result.first()
|
||||||
|
|
||||||
|
if not report_session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Interview report not found. Run analysis first using POST /analysis/interview-report/{resume_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
report, interview_session = report_session
|
||||||
|
|
||||||
|
# Если PDF уже существует, возвращаем его
|
||||||
|
if report.pdf_report_url:
|
||||||
|
return PDFGenerationResponse(
|
||||||
|
message="PDF report already exists",
|
||||||
|
resume_id=resume_id,
|
||||||
|
candidate_name=resume.applicant_name,
|
||||||
|
pdf_url=report.pdf_report_url,
|
||||||
|
status="exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Генерируем PDF отчет
|
||||||
|
try:
|
||||||
|
# Получаем позицию из связанной вакансии
|
||||||
|
from app.models.vacancy import Vacancy
|
||||||
|
|
||||||
|
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
||||||
|
vacancy_result = await session.execute(vacancy_stmt)
|
||||||
|
vacancy = vacancy_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
position = vacancy.title if vacancy else "Позиция не указана"
|
||||||
|
|
||||||
|
# Генерируем и загружаем PDF
|
||||||
|
pdf_url = await pdf_report_service.generate_and_upload_pdf(
|
||||||
|
report, resume.applicant_name, position
|
||||||
|
)
|
||||||
|
|
||||||
|
if not pdf_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to generate or upload PDF report"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Обновляем отчет в БД
|
||||||
|
from sqlmodel import update
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
update(InterviewReport)
|
||||||
|
.where(InterviewReport.id == report.id)
|
||||||
|
.values(pdf_report_url=pdf_url)
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return PDFGenerationResponse(
|
||||||
|
message="PDF report generated successfully",
|
||||||
|
resume_id=resume_id,
|
||||||
|
candidate_name=resume.applicant_name,
|
||||||
|
pdf_url=pdf_url,
|
||||||
|
status="generated",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error generating PDF report: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/report-data/{resume_id}")
|
@router.get("/report-data/{resume_id}")
|
||||||
async def get_report_data(
|
async def get_report_data(
|
||||||
resume_id: int,
|
resume_id: int,
|
||||||
@ -318,7 +428,7 @@ async def get_report_data(
|
|||||||
.where(InterviewSession.resume_id == resume_id)
|
.where(InterviewSession.resume_id == resume_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await session.exec(statement)
|
result = await session.execute(statement)
|
||||||
report_session = result.first()
|
report_session = result.first()
|
||||||
|
|
||||||
if not report_session:
|
if not report_session:
|
||||||
@ -326,10 +436,19 @@ async def get_report_data(
|
|||||||
|
|
||||||
report, interview_session = report_session
|
report, interview_session = report_session
|
||||||
|
|
||||||
|
# Получаем позицию из связанной вакансии
|
||||||
|
from app.models.vacancy import Vacancy
|
||||||
|
|
||||||
|
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
||||||
|
vacancy_result = await session.execute(vacancy_stmt)
|
||||||
|
vacancy = vacancy_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
position = vacancy.title if vacancy else "Позиция не указана"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"report_id": report.id,
|
"report_id": report.id,
|
||||||
"candidate_name": resume.applicant_name,
|
"candidate_name": resume.applicant_name,
|
||||||
"position": "Unknown Position", # Можно расширить через vacancy
|
"position": position,
|
||||||
"interview_date": report.created_at.isoformat(),
|
"interview_date": report.created_at.isoformat(),
|
||||||
"overall_score": report.overall_score,
|
"overall_score": report.overall_score,
|
||||||
"recommendation": report.recommendation.value,
|
"recommendation": report.recommendation.value,
|
||||||
|
@ -182,6 +182,7 @@ class InterviewRoomService:
|
|||||||
token=token,
|
token=token,
|
||||||
room_name=interview_session.room_name,
|
room_name=interview_session.room_name,
|
||||||
server_url=self.livekit_url,
|
server_url=self.livekit_url,
|
||||||
|
session_id=interview_session.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -6,6 +6,8 @@ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
|
|||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||||
from reportlab.lib.units import inch
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
from reportlab.platypus import (
|
from reportlab.platypus import (
|
||||||
Paragraph,
|
Paragraph,
|
||||||
SimpleDocTemplate,
|
SimpleDocTemplate,
|
||||||
@ -22,9 +24,43 @@ class PDFReportService:
|
|||||||
"""Сервис для генерации PDF отчетов по интервью"""
|
"""Сервис для генерации PDF отчетов по интервью"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self._register_fonts()
|
||||||
self.styles = getSampleStyleSheet()
|
self.styles = getSampleStyleSheet()
|
||||||
self._setup_custom_styles()
|
self._setup_custom_styles()
|
||||||
|
|
||||||
|
def _register_fonts(self):
|
||||||
|
"""Регистрация шрифтов для поддержки кириллицы"""
|
||||||
|
try:
|
||||||
|
# Пытаемся использовать системные шрифты Windows
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Пути к шрифтам Windows
|
||||||
|
fonts_dir = "C:/Windows/Fonts"
|
||||||
|
|
||||||
|
# Регистрируем Arial для русского текста
|
||||||
|
if os.path.exists(f"{fonts_dir}/arial.ttf"):
|
||||||
|
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/arial.ttf"))
|
||||||
|
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/arialbd.ttf"))
|
||||||
|
# Альтернативно используем Calibri
|
||||||
|
elif os.path.exists(f"{fonts_dir}/calibri.ttf"):
|
||||||
|
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/calibri.ttf"))
|
||||||
|
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/calibrib.ttf"))
|
||||||
|
# Если ничего не найдено, используем встроенный DejaVu
|
||||||
|
else:
|
||||||
|
# Fallback к стандартным шрифтам ReportLab с поддержкой Unicode
|
||||||
|
from reportlab.lib.fonts import addMapping
|
||||||
|
addMapping('Arial-Unicode', 0, 0, 'Helvetica')
|
||||||
|
addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
|
||||||
|
addMapping('Arial-Unicode', 0, 1, 'Helvetica-Oblique')
|
||||||
|
addMapping('Arial-Unicode', 1, 1, 'Helvetica-BoldOblique')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not register custom fonts: {e}")
|
||||||
|
# Используем стандартные шрифты как fallback
|
||||||
|
from reportlab.lib.fonts import addMapping
|
||||||
|
addMapping('Arial-Unicode', 0, 0, 'Helvetica')
|
||||||
|
addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
|
||||||
|
|
||||||
def _setup_custom_styles(self):
|
def _setup_custom_styles(self):
|
||||||
"""Настройка кастомных стилей для документа"""
|
"""Настройка кастомных стилей для документа"""
|
||||||
# Заголовок отчета
|
# Заголовок отчета
|
||||||
@ -36,7 +72,7 @@ class PDFReportService:
|
|||||||
spaceAfter=30,
|
spaceAfter=30,
|
||||||
alignment=TA_CENTER,
|
alignment=TA_CENTER,
|
||||||
textColor=colors.HexColor("#2E3440"),
|
textColor=colors.HexColor("#2E3440"),
|
||||||
fontName="Helvetica-Bold",
|
fontName="Arial-Unicode-Bold",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,7 +85,7 @@ class PDFReportService:
|
|||||||
spaceAfter=12,
|
spaceAfter=12,
|
||||||
spaceBefore=20,
|
spaceBefore=20,
|
||||||
textColor=colors.HexColor("#5E81AC"),
|
textColor=colors.HexColor("#5E81AC"),
|
||||||
fontName="Helvetica-Bold",
|
fontName="Arial-Unicode-Bold",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -62,19 +98,20 @@ class PDFReportService:
|
|||||||
spaceAfter=8,
|
spaceAfter=8,
|
||||||
spaceBefore=15,
|
spaceBefore=15,
|
||||||
textColor=colors.HexColor("#81A1C1"),
|
textColor=colors.HexColor("#81A1C1"),
|
||||||
fontName="Helvetica-Bold",
|
fontName="Arial-Unicode-Bold",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Обычный текст
|
# Обычный текст
|
||||||
self.styles.add(
|
self.styles.add(
|
||||||
ParagraphStyle(
|
ParagraphStyle(
|
||||||
name="BodyText",
|
name="CustomBodyText",
|
||||||
parent=self.styles["Normal"],
|
parent=self.styles["Normal"],
|
||||||
fontSize=10,
|
fontSize=10,
|
||||||
spaceAfter=6,
|
spaceAfter=6,
|
||||||
alignment=TA_JUSTIFY,
|
alignment=TA_JUSTIFY,
|
||||||
textColor=colors.HexColor("#2E3440"),
|
textColor=colors.HexColor("#2E3440"),
|
||||||
|
fontName="Arial-Unicode",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -86,7 +123,7 @@ class PDFReportService:
|
|||||||
fontSize=12,
|
fontSize=12,
|
||||||
alignment=TA_CENTER,
|
alignment=TA_CENTER,
|
||||||
textColor=colors.HexColor("#5E81AC"),
|
textColor=colors.HexColor("#5E81AC"),
|
||||||
fontName="Helvetica-Bold",
|
fontName="Arial-Unicode-Bold",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -132,7 +169,7 @@ class PDFReportService:
|
|||||||
["Кандидат:", candidate_name],
|
["Кандидат:", candidate_name],
|
||||||
["Позиция:", position],
|
["Позиция:", position],
|
||||||
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
|
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
|
||||||
["Общий балл:", f"<b>{report.overall_score}/100</b>"],
|
["Общий балл:", f"{report.overall_score}/100"],
|
||||||
["Рекомендация:", self._format_recommendation(report.recommendation)],
|
["Рекомендация:", self._format_recommendation(report.recommendation)],
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -141,7 +178,8 @@ class PDFReportService:
|
|||||||
TableStyle(
|
TableStyle(
|
||||||
[
|
[
|
||||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
||||||
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
("FONTNAME", (0, 0), (0, -1), "Arial-Unicode-Bold"),
|
||||||
|
("FONTNAME", (1, 0), (-1, -1), "Arial-Unicode"), # Правая колонка обычным шрифтом
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||||
@ -154,57 +192,71 @@ class PDFReportService:
|
|||||||
# Оценки по критериям
|
# Оценки по критериям
|
||||||
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
|
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
|
||||||
|
|
||||||
|
# Стиль для текста в таблице с автопереносом
|
||||||
|
table_text_style = ParagraphStyle(
|
||||||
|
name="TableText",
|
||||||
|
parent=self.styles["Normal"],
|
||||||
|
fontSize=8,
|
||||||
|
fontName="Arial-Unicode",
|
||||||
|
leading=10,
|
||||||
|
)
|
||||||
|
|
||||||
criteria_data = [
|
criteria_data = [
|
||||||
["Критерий", "Балл", "Обоснование", "Риски"],
|
|
||||||
[
|
[
|
||||||
"Технические навыки",
|
Paragraph("Критерий", self.styles["CustomBodyText"]),
|
||||||
f"{report.technical_skills_score}/100",
|
Paragraph("Балл", self.styles["CustomBodyText"]),
|
||||||
report.technical_skills_justification or "—",
|
Paragraph("Обоснование", self.styles["CustomBodyText"]),
|
||||||
report.technical_skills_concerns or "—",
|
Paragraph("Риски", self.styles["CustomBodyText"]),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Релевантность опыта",
|
Paragraph("Технические навыки", table_text_style),
|
||||||
f"{report.experience_relevance_score}/100",
|
Paragraph(f"{report.technical_skills_score}/100", table_text_style),
|
||||||
report.experience_relevance_justification or "—",
|
Paragraph(report.technical_skills_justification or "—", table_text_style),
|
||||||
report.experience_relevance_concerns or "—",
|
Paragraph(report.technical_skills_concerns or "—", table_text_style),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Коммуникация",
|
Paragraph("Релевантность опыта", table_text_style),
|
||||||
f"{report.communication_score}/100",
|
Paragraph(f"{report.experience_relevance_score}/100", table_text_style),
|
||||||
report.communication_justification or "—",
|
Paragraph(report.experience_relevance_justification or "—", table_text_style),
|
||||||
report.communication_concerns or "—",
|
Paragraph(report.experience_relevance_concerns or "—", table_text_style),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Решение задач",
|
Paragraph("Коммуникация", table_text_style),
|
||||||
f"{report.problem_solving_score}/100",
|
Paragraph(f"{report.communication_score}/100", table_text_style),
|
||||||
report.problem_solving_justification or "—",
|
Paragraph(report.communication_justification or "—", table_text_style),
|
||||||
report.problem_solving_concerns or "—",
|
Paragraph(report.communication_concerns or "—", table_text_style),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"Культурное соответствие",
|
Paragraph("Решение задач", table_text_style),
|
||||||
f"{report.cultural_fit_score}/100",
|
Paragraph(f"{report.problem_solving_score}/100", table_text_style),
|
||||||
report.cultural_fit_justification or "—",
|
Paragraph(report.problem_solving_justification or "—", table_text_style),
|
||||||
report.cultural_fit_concerns or "—",
|
Paragraph(report.problem_solving_concerns or "—", table_text_style),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph("Культурное соответствие", table_text_style),
|
||||||
|
Paragraph(f"{report.cultural_fit_score}/100", table_text_style),
|
||||||
|
Paragraph(report.cultural_fit_justification or "—", table_text_style),
|
||||||
|
Paragraph(report.cultural_fit_concerns or "—", table_text_style),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
criteria_table = Table(
|
criteria_table = Table(
|
||||||
criteria_data, colWidths=[2 * inch, 0.8 * inch, 2.2 * inch, 1.8 * inch]
|
criteria_data, colWidths=[1.5 * inch, 0.6 * inch, 2.8 * inch, 2.1 * inch]
|
||||||
)
|
)
|
||||||
criteria_table.setStyle(
|
criteria_table.setStyle(
|
||||||
TableStyle(
|
TableStyle(
|
||||||
[
|
[
|
||||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
|
||||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
||||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
("ALIGN", (1, 1), (1, -1), "CENTER"), # Центрирование баллов
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
("ALIGN", (0, 0), (-1, -1), "LEFT"), # Остальное слева
|
||||||
("ALIGN", (1, 1), (1, -1), "CENTER"),
|
|
||||||
("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
|
("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
|
||||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 8),
|
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8F9FA")]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -282,7 +334,7 @@ class PDFReportService:
|
|||||||
if report.strengths:
|
if report.strengths:
|
||||||
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
|
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
|
||||||
for strength in report.strengths:
|
for strength in report.strengths:
|
||||||
story.append(Paragraph(f"• {strength}", self.styles["BodyText"]))
|
story.append(Paragraph(f"• {strength}", self.styles["CustomBodyText"]))
|
||||||
story.append(Spacer(1, 10))
|
story.append(Spacer(1, 10))
|
||||||
|
|
||||||
if report.weaknesses:
|
if report.weaknesses:
|
||||||
@ -290,7 +342,7 @@ class PDFReportService:
|
|||||||
Paragraph("Области для развития:", self.styles["SubHeader"])
|
Paragraph("Области для развития:", self.styles["SubHeader"])
|
||||||
)
|
)
|
||||||
for weakness in report.weaknesses:
|
for weakness in report.weaknesses:
|
||||||
story.append(Paragraph(f"• {weakness}", self.styles["BodyText"]))
|
story.append(Paragraph(f"• {weakness}", self.styles["CustomBodyText"]))
|
||||||
story.append(Spacer(1, 10))
|
story.append(Spacer(1, 10))
|
||||||
|
|
||||||
# Красные флаги
|
# Красные флаги
|
||||||
@ -302,7 +354,7 @@ class PDFReportService:
|
|||||||
f"⚠ {red_flag}",
|
f"⚠ {red_flag}",
|
||||||
ParagraphStyle(
|
ParagraphStyle(
|
||||||
name="RedFlag",
|
name="RedFlag",
|
||||||
parent=self.styles["BodyText"],
|
parent=self.styles["CustomBodyText"],
|
||||||
textColor=colors.HexColor("#BF616A"),
|
textColor=colors.HexColor("#BF616A"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -312,45 +364,9 @@ class PDFReportService:
|
|||||||
# Рекомендации и следующие шаги
|
# Рекомендации и следующие шаги
|
||||||
if report.next_steps:
|
if report.next_steps:
|
||||||
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
|
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
|
||||||
story.append(Paragraph(report.next_steps, self.styles["BodyText"]))
|
story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
|
||||||
story.append(Spacer(1, 15))
|
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(Spacer(1, 30))
|
||||||
story.append(
|
story.append(
|
||||||
@ -362,6 +378,7 @@ class PDFReportService:
|
|||||||
fontSize=8,
|
fontSize=8,
|
||||||
alignment=TA_CENTER,
|
alignment=TA_CENTER,
|
||||||
textColor=colors.HexColor("#4C566A"),
|
textColor=colors.HexColor("#4C566A"),
|
||||||
|
fontName="Arial-Unicode",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -374,10 +391,10 @@ class PDFReportService:
|
|||||||
def _format_recommendation(self, recommendation: RecommendationType) -> str:
|
def _format_recommendation(self, recommendation: RecommendationType) -> str:
|
||||||
"""Форматирует рекомендацию для отображения"""
|
"""Форматирует рекомендацию для отображения"""
|
||||||
recommendation_map = {
|
recommendation_map = {
|
||||||
RecommendationType.STRONGLY_RECOMMEND: "✅ Настоятельно рекомендуем",
|
RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем",
|
||||||
RecommendationType.RECOMMEND: "👍 Рекомендуем",
|
RecommendationType.RECOMMEND: "Рекомендуем",
|
||||||
RecommendationType.CONSIDER: "🤔 Рассмотреть кандидатуру",
|
RecommendationType.CONSIDER: "Рассмотреть кандидатуру",
|
||||||
RecommendationType.REJECT: "❌ Не рекомендуем",
|
RecommendationType.REJECT: "Не рекомендуем",
|
||||||
}
|
}
|
||||||
return recommendation_map.get(recommendation, str(recommendation))
|
return recommendation_map.get(recommendation, str(recommendation))
|
||||||
|
|
||||||
@ -408,11 +425,12 @@ class PDFReportService:
|
|||||||
safe_name = safe_name.replace(" ", "_")
|
safe_name = safe_name.replace(" ", "_")
|
||||||
filename = f"interview_report_{safe_name}_{report.id}.pdf"
|
filename = f"interview_report_{safe_name}_{report.id}.pdf"
|
||||||
|
|
||||||
# Загружаем в S3
|
# Загружаем в S3 с публичным доступом
|
||||||
file_url = await s3_service.upload_file(
|
file_url = await s3_service.upload_file(
|
||||||
file_content=pdf_bytes,
|
file_content=pdf_bytes,
|
||||||
file_name=filename,
|
file_name=filename,
|
||||||
content_type="application/pdf",
|
content_type="application/pdf",
|
||||||
|
public=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return file_url
|
return file_url
|
||||||
|
Loading…
Reference in New Issue
Block a user