import io import os import shutil import tempfile from datetime import datetime from urllib.parse import quote import requests from jinja2 import Template from playwright.async_api import async_playwright from app.core.s3 import s3_service from app.models.interview_report import InterviewReport, RecommendationType class PDFReportService: """Сервис для генерации PDF отчетов по интервью на основе HTML шаблона""" def __init__(self): self.template_path = "templates/interview_report.html" self._setup_fonts() def _download_font(self, url: str, dest_path: str) -> str: """Скачивает шрифт по URL в dest_path (перезаписывает если нужно).""" os.makedirs(os.path.dirname(dest_path), exist_ok=True) try: resp = requests.get(url, stream=True, timeout=15) resp.raise_for_status() with open(dest_path, "wb") as f: shutil.copyfileobj(resp.raw, f) print(f"[OK] Downloaded font {url} -> {dest_path}") return dest_path except Exception as e: print(f"[ERROR] Failed to download font {url}: {e}") raise def _register_local_fonts(self, regular_path: str, bold_path: str): """Регистрирует шрифты в ReportLab, чтобы xhtml2pdf мог ими пользоваться.""" try: from reportlab.lib.fonts import addMapping from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont pdfmetrics.registerFont(TTFont("DejaVuSans", regular_path)) pdfmetrics.registerFont(TTFont("DejaVuSans-Bold", bold_path)) # mapping: family, bold(1)/normal(0), italic(1)/normal(0), fontkey addMapping("DejaVuSans", 0, 0, "DejaVuSans") addMapping("DejaVuSans", 1, 0, "DejaVuSans-Bold") self.available_fonts = ["DejaVuSans", "DejaVuSans-Bold"] print("[OK] Registered DejaVu fonts in ReportLab") except Exception as e: print(f"[ERROR] Register fonts failed: {e}") self.available_fonts = [] def _setup_fonts(self): """Настройка русских шрифтов для xhtml2pdf""" self.available_fonts = [] try: from reportlab.lib.fonts import addMapping from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont # Используем скачанные DejaVu шрифты fonts_dir = "static/fonts" font_paths = [ (os.path.join(fonts_dir, "DejaVuSans.ttf"), "DejaVu", False, False), (os.path.join(fonts_dir, "DejaVuSans-Bold.ttf"), "DejaVu", True, False), ] for font_path, font_name, is_bold, is_italic in font_paths: if os.path.exists(font_path): try: font_key = f"{font_name}" if is_bold: font_key += "-Bold" if is_italic: font_key += "-Italic" # Проверяем, что шрифт можно загрузить test_font = TTFont(font_key, font_path) pdfmetrics.registerFont(test_font) addMapping(font_name, is_bold, is_italic, font_key) self.available_fonts.append(font_key) print(f"[OK] Successfully registered font: {font_key}") except Exception as e: print(f"[ERROR] Failed to register font {font_path}: {e}") else: print(f"[ERROR] Font file not found: {font_path}") except Exception as e: print(f"[ERROR] Font setup failed: {e}") print(f"Available fonts: {self.available_fonts}") def _get_font_css(self) -> str: """Возвращает CSS с подключением локальных шрифтов (скачивает при необходимости).""" # paths локальные fonts_dir = os.path.abspath("static/fonts").replace("\\", "/") regular_local = os.path.join(fonts_dir, "DejaVuSans.ttf").replace("\\", "/") bold_local = os.path.join(fonts_dir, "DejaVuSans-Bold.ttf").replace("\\", "/") # твои удалённые URL (используй свои) remote_regular = ( "https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans.ttf" ) remote_bold = "https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans-Bold.ttf" # скачиваем если локально нет try: if not os.path.exists(regular_local) or os.path.getsize(regular_local) == 0: self._download_font(remote_regular, regular_local) if not os.path.exists(bold_local) or os.path.getsize(bold_local) == 0: self._download_font(remote_bold, bold_local) except Exception as e: print("[WARNING] Failed to ensure local fonts:", e) # регистрируем в ReportLab (чтобы гарантировать поддержку кириллицы) try: self._register_local_fonts(regular_local, bold_local) except Exception as e: print("[WARNING] Font registration error:", e) # используем file:/// абсолютный путь в src и УБИРАЕМ format('...') — это важно # url-энкодим путь на случай пробелов reg_quoted = quote(regular_local) bold_quoted = quote(bold_local) font_css = f""" """ return font_css def _load_html_template(self) -> str: """Загружает HTML шаблон из файла""" try: with open(self.template_path, encoding="utf-8") as file: return file.read() except FileNotFoundError: raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}") def _format_concerns_field(self, concerns): """Форматирует поле concerns для отображения""" if not concerns: return "—" if isinstance(concerns, list): return "; ".join(concerns) elif isinstance(concerns, str): return concerns else: return str(concerns) def _format_list_field(self, field_value) -> str: """Форматирует поле со списком для отображения""" if not field_value: return "Не указаны" if isinstance(field_value, list): return "\n".join([f"• {item}" for item in field_value]) elif isinstance(field_value, str): return field_value else: return str(field_value) def _get_score_class(self, score: int) -> str: """Возвращает CSS класс для цвета оценки""" if score >= 90: return "score-green" # STRONGLY_RECOMMEND elif score >= 75: return "score-light-green" # RECOMMEND elif score >= 60: return "score-orange" # CONSIDER else: return "score-red" # REJECT def _format_recommendation(self, recommendation: RecommendationType) -> tuple: """Форматирует рекомендацию для отображения""" if recommendation == RecommendationType.STRONGLY_RECOMMEND: return ("Настоятельно рекомендуем", "recommend-button") elif recommendation == RecommendationType.RECOMMEND: return ("Рекомендуем", "recommend-button") elif recommendation == RecommendationType.CONSIDER: return ("К рассмотрению", "consider-button") else: # REJECT return ("Не рекомендуем", "reject-button") def link_callback(self, uri, rel): """Скачивает удалённый ресурс в temp файл и возвращает путь (для xhtml2pdf).""" # remote -> сохранить во временный файл и вернуть путь if uri.startswith("http://") or uri.startswith("https://"): try: r = requests.get(uri, stream=True, timeout=15) r.raise_for_status() fd, tmp_path = tempfile.mkstemp(suffix=os.path.basename(uri)) with os.fdopen(fd, "wb") as f: for chunk in r.iter_content(8192): f.write(chunk) return tmp_path except Exception as e: raise Exception(f"Не удалось скачать ресурс {uri}: {e}") # file:///path -> без префикса if uri.startswith("file:///"): return uri[7:] # локальные относительные пути if os.path.isfile(uri): return uri # fallback — возвращаем как есть (pisa попробует обработать) return uri def fetch_resources(self, uri, rel): # Разрешаем xhtml2pdf скачивать https return self.link_callback(uri, rel) async def generate_pdf_report( self, interview_report: InterviewReport, candidate_name: str = None, position: str = None, resume_file_url: str = None, ) -> bytes: """ Генерирует PDF отчет на основе HTML шаблона Args: interview_report: Данные отчета по интервью Returns: bytes: PDF файл в виде байтов """ try: # Загружаем HTML шаблон html_template = self._load_html_template() # Подготавливаем данные для шаблона template_data = self._prepare_template_data( interview_report, candidate_name or "Не указано", position or "Не указана", resume_file_url, ) # Рендерим HTML с данными template = Template(html_template) rendered_html = template.render(**template_data) # Получаем CSS с проверенными шрифтами font_css = self._get_font_css() # Вставляем стили if "" in rendered_html: rendered_html = rendered_html.replace("", f"{font_css}") else: rendered_html = font_css + rendered_html with open("debug.html", "w", encoding="utf-8") as f: f.write(rendered_html) # Генерируем PDF из debug.html с помощью Playwright print("[OK] Using Playwright to generate PDF from debug.html") async def generate_pdf(): async with async_playwright() as p: browser = await p.chromium.launch() page = await browser.new_page() await page.goto(f"file://{os.path.abspath('debug.html')}") await page.wait_for_load_state("networkidle") pdf_bytes = await page.pdf( format="A4", margin={ "top": "0.75in", "bottom": "0.75in", "left": "0.75in", "right": "0.75in", }, print_background=True, ) await browser.close() return pdf_bytes pdf_bytes = await generate_pdf() return pdf_bytes except Exception as e: raise Exception(f"Ошибка при генерации PDF: {str(e)}") def _prepare_template_data( self, interview_report: InterviewReport, candidate_name: str, position: str, resume_file_url: str = None, ) -> dict: """Подготавливает данные для HTML шаблона""" # Используем переданные параметры как в старой версии resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме # Форматируем дату интервью interview_date = "Не указана" if ( interview_report.interview_session and interview_report.interview_session.interview_start_time ): interview_date = ( interview_report.interview_session.interview_start_time.strftime( "%d.%m.%Y %H:%M" ) ) # Общий балл и рекомендация overall_score = interview_report.overall_score or 0 recommendation_text, recommendation_class = self._format_recommendation( interview_report.recommendation ) # Сильные стороны и области развития (используем правильные поля модели) strengths = ( self._format_list_field(interview_report.strengths) if interview_report.strengths else "Не указаны" ) areas_for_development = ( self._format_list_field(interview_report.weaknesses) if interview_report.weaknesses else "Не указаны" ) # Детальная оценка - всегда все критерии, как в старой версии evaluation_criteria = [ { "name": "Технические навыки", "score": interview_report.technical_skills_score or 0, "score_class": self._get_score_class( interview_report.technical_skills_score or 0 ), "justification": interview_report.technical_skills_justification or "—", "concerns": self._format_concerns_field( interview_report.technical_skills_concerns ), }, { "name": "Релевантность опыта", "score": interview_report.experience_relevance_score or 0, "score_class": self._get_score_class( interview_report.experience_relevance_score or 0 ), "justification": interview_report.experience_relevance_justification or "—", "concerns": self._format_concerns_field( interview_report.experience_relevance_concerns ), }, { "name": "Коммуникация", "score": interview_report.communication_score or 0, "score_class": self._get_score_class( interview_report.communication_score or 0 ), "justification": interview_report.communication_justification or "—", "concerns": self._format_concerns_field( interview_report.communication_concerns ), }, { "name": "Решение задач", "score": interview_report.problem_solving_score or 0, "score_class": self._get_score_class( interview_report.problem_solving_score or 0 ), "justification": interview_report.problem_solving_justification or "—", "concerns": self._format_concerns_field( interview_report.problem_solving_concerns ), }, { "name": "Культурное соответствие", "score": interview_report.cultural_fit_score or 0, "score_class": self._get_score_class( interview_report.cultural_fit_score or 0 ), "justification": interview_report.cultural_fit_justification or "—", "concerns": self._format_concerns_field( interview_report.cultural_fit_concerns ), }, ] # Красные флаги - используем поле модели напрямую red_flags = interview_report.red_flags or [] # Ссылка на резюме (уже определена выше) # ID отчета report_id = f"#{interview_report.id}" if interview_report.id else "#0" # Дата генерации отчета generation_date = datetime.now().strftime("%d.%m.%Y %H:%M") return { "report_id": report_id, "candidate_name": candidate_name, "position": position, "interview_date": interview_date, "overall_score": overall_score, "recommendation_text": recommendation_text, "recommendation_class": recommendation_class, "strengths": strengths, "areas_for_development": areas_for_development, "evaluation_criteria": evaluation_criteria, "red_flags": red_flags, "resume_url": resume_url, "generation_date": generation_date, } async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str: """ Загружает PDF файл в S3 и возвращает публичную ссылку Args: pdf_bytes: PDF файл в виде байтов filename: Имя файла Returns: str: Публичная ссылка на файл в S3 """ try: pdf_stream = io.BytesIO(pdf_bytes) # Загружаем с публичным доступом file_url = await s3_service.upload_file( pdf_stream, filename, content_type="application/pdf", public=True ) return file_url except Exception as e: raise Exception(f"Ошибка при загрузке PDF в S3: {str(e)}") async def generate_and_upload_pdf( self, report: InterviewReport, candidate_name: str = None, position: str = None, resume_file_url: str = None, ) -> str: """ Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости) Args: report: Отчет по интервью candidate_name: Имя кандидата (не используется, берется из отчета) position: Позиция (не используется, берется из отчета) Returns: str: Публичная ссылка на PDF файл """ try: # Генерируем PDF pdf_bytes = await self.generate_pdf_report( report, candidate_name, position, resume_file_url ) # Создаем имя файла - используем переданный параметр как в старой версии safe_name = ( candidate_name if candidate_name and candidate_name != "Не указано" else "candidate" ) safe_name = "".join( c for c in safe_name if c.isalnum() or c in (" ", "-", "_") ).strip() filename = f"interview_report_{safe_name}_{report.id}.pdf" # Загружаем в S3 pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename) return pdf_url except Exception as e: raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}") # Экземпляр сервиса pdf_report_service = PDFReportService()