First commite
This commit is contained in:
30
cv/services/dowload/base.py
Normal file
30
cv/services/dowload/base.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, Protocol
|
||||
|
||||
|
||||
class ProfileSerializer:
|
||||
"""Сериализатор данных профиля для рендереров."""
|
||||
|
||||
def serialize(self, profile) -> Dict[str, Any]:
|
||||
return {
|
||||
"full_name": profile.full_name,
|
||||
"role": getattr(profile, "role", ""),
|
||||
"summary": getattr(profile, "summary", ""),
|
||||
"location": getattr(profile, "location", ""),
|
||||
"languages": getattr(profile, "languages", []) or [],
|
||||
"contacts": {
|
||||
"email": getattr(profile, "email", ""),
|
||||
"phone": getattr(profile, "phone", ""),
|
||||
"telegram": getattr(profile, "telegram", ""),
|
||||
},
|
||||
"experience": list(profile.experience.all()),
|
||||
"skills_map": list(profile.skills_map.all()),
|
||||
}
|
||||
|
||||
|
||||
class Renderer(Protocol):
|
||||
def render(self, profile) -> BytesIO: ...
|
||||
|
||||
|
||||
105
cv/services/dowload/docx.py
Normal file
105
cv/services/dowload/docx.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from cv.services.dowload.pdf import ProfileSerializer
|
||||
|
||||
|
||||
class DocxRenderer:
|
||||
def __init__(self, ):
|
||||
self.serializer = ProfileSerializer()
|
||||
|
||||
@staticmethod
|
||||
def _add_divider(document: Document) -> None:
|
||||
p = document.add_paragraph()
|
||||
fmt = p.paragraph_format
|
||||
fmt.space_before = Pt(6)
|
||||
fmt.space_after = Pt(6)
|
||||
p_elm = p._p
|
||||
pPr = p_elm.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), "6")
|
||||
bottom.set(qn("w:space"), "1")
|
||||
bottom.set(qn("w:color"), "2a3347")
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
@staticmethod
|
||||
def _month_year(dt) -> str:
|
||||
ru_months = {
|
||||
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
|
||||
5: "май", 6: "июнь", 7: "июль", 8: "август",
|
||||
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь",
|
||||
}
|
||||
return f"{ru_months.get(dt.month, dt.strftime('%B')).capitalize()} {dt.year}"
|
||||
|
||||
def render(self, profile) -> BytesIO:
|
||||
data = self.serializer.serialize(profile)
|
||||
buf = BytesIO()
|
||||
doc = Document()
|
||||
# Заголовок
|
||||
doc.core_properties.title = data["full_name"]
|
||||
if data["role"]:
|
||||
doc.core_properties.subject = data["role"]
|
||||
doc.add_heading(data["full_name"], level=0)
|
||||
if data["role"]:
|
||||
doc.add_paragraph(data["role"])
|
||||
if data["summary"]:
|
||||
doc.add_paragraph(data["summary"])
|
||||
meta_parts: list[str] = []
|
||||
if data["location"]:
|
||||
meta_parts.append(data["location"])
|
||||
if data["languages"]:
|
||||
meta_parts.append("Языки: " + ", ".join(data["languages"]))
|
||||
if meta_parts:
|
||||
doc.add_paragraph(" • ".join(meta_parts))
|
||||
|
||||
# Контакты
|
||||
doc.add_heading("Контакты", level=1)
|
||||
contacts = data["contacts"]
|
||||
if contacts.get("email"):
|
||||
doc.add_paragraph(f"Email: {contacts['email']}")
|
||||
if contacts.get("phone"):
|
||||
doc.add_paragraph(f"Телефон: {contacts['phone']}")
|
||||
if contacts.get("telegram"):
|
||||
doc.add_paragraph(f"Telegram: {contacts['telegram']}")
|
||||
|
||||
# Опыт
|
||||
doc.add_heading("Опыт работы", level=1)
|
||||
for i, e in enumerate(data["experience"]):
|
||||
if i:
|
||||
self._add_divider(doc)
|
||||
period = f"{self._month_year(e.start_date)} — {(self._month_year(e.end_date) if e.end_date else 'настоящее время')}"
|
||||
p_company = doc.add_paragraph()
|
||||
run_company = p_company.add_run(e.company)
|
||||
run_company.bold = True
|
||||
run_company.font.underline = True
|
||||
run_company.font.color.rgb = RGBColor(0x2A, 0x33, 0x47)
|
||||
p_period = doc.add_paragraph(period)
|
||||
for r in p_period.runs:
|
||||
r.font.color.rgb = RGBColor(0x99, 0xA2, 0xB2)
|
||||
if e.summary:
|
||||
doc.add_paragraph(e.summary)
|
||||
for a in (e.achievements or []):
|
||||
if a:
|
||||
doc.add_paragraph(a, style="List Bullet")
|
||||
if e.tech:
|
||||
doc.add_paragraph("Технологии: " + ", ".join([t for t in e.tech if t]))
|
||||
|
||||
# Навыки
|
||||
doc.add_heading("Навыки", level=1)
|
||||
for g in data["skills_map"]:
|
||||
if g.items:
|
||||
doc.add_paragraph(f"{g.group}: " + ", ".join([i for i in g.items if i]))
|
||||
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
107
cv/services/dowload/pdf.py
Normal file
107
cv/services/dowload/pdf.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
from io import BytesIO
|
||||
from weasyprint import HTML
|
||||
|
||||
from cv.services.dowload.base import ProfileSerializer
|
||||
|
||||
|
||||
class PdfRenderer:
|
||||
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF."""
|
||||
|
||||
def __init__(self):
|
||||
# template не используется, оставлен для совместимости интерфейса
|
||||
self.serializer = ProfileSerializer()
|
||||
|
||||
def render(self, profile) -> BytesIO:
|
||||
data = self.serializer.serialize(profile)
|
||||
# Минимальный, печатный HTML с безопасными стилями
|
||||
parts = []
|
||||
parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
|
||||
parts.append(f"<title>{data['full_name']} — Резюме (PDF)</title>")
|
||||
parts.append("""
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #111; }
|
||||
h1 { margin: 0 0 6mm 0; font-size: 20pt; }
|
||||
h2 { margin: 8mm 0 3mm 0; font-size: 13pt; border-bottom: 1px solid #ccc; padding-bottom: 2mm; }
|
||||
p { margin: 0 0 3mm 0; line-height: 1.4; }
|
||||
.meta { color: #555; }
|
||||
.tag { display: inline-block; border: 1px solid #ddd; padding: 2px 6px; border-radius: 10px; font-size: 9pt; color: #444; margin-right: 4px; }
|
||||
.item { margin: 0 0 5mm 0; }
|
||||
.item-title { font-weight: 700; }
|
||||
.item-period { color: #666; }
|
||||
ul { margin: 2mm 0 2mm 6mm; }
|
||||
</style>
|
||||
</head><body>
|
||||
""")
|
||||
# Header
|
||||
parts.append(f"<h1>{data['full_name']}</h1>")
|
||||
if data["role"]:
|
||||
parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
|
||||
if data["summary"]:
|
||||
parts.append(f"<p>{data['summary']}</p>")
|
||||
meta_tags = []
|
||||
if data["location"]:
|
||||
meta_tags.append(f"<span class='tag'>{data['location']}</span>")
|
||||
if data["languages"]:
|
||||
meta_tags.append(f"<span class='tag'>Языки: {', '.join(data['languages'])}</span>")
|
||||
if meta_tags:
|
||||
parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
|
||||
|
||||
# Contacts
|
||||
contacts = data["contacts"]
|
||||
parts.append("<h2>Контакты</h2><p>")
|
||||
if contacts.get("email"):
|
||||
parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
|
||||
if contacts.get("phone"):
|
||||
parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
|
||||
if contacts.get("telegram"):
|
||||
parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
|
||||
parts.append("</p>")
|
||||
|
||||
# Experience
|
||||
parts.append("<h2>Опыт работы</h2>")
|
||||
exp = data["experience"]
|
||||
if exp:
|
||||
for e in exp:
|
||||
parts.append("<div class='item'>")
|
||||
parts.append(f"<div class='item-title'>{e.company}</div>")
|
||||
period = ""
|
||||
if getattr(e, 'start_date', None):
|
||||
period += e.start_date.strftime("%B %Y")
|
||||
period += " — "
|
||||
period += e.end_date.strftime("%B %Y") if getattr(e, 'end_date', None) else "настоящее время"
|
||||
parts.append(f"<div class='item-period'>{period}</div>")
|
||||
if e.summary:
|
||||
parts.append(f"<p>{e.summary}</p>")
|
||||
if e.achievements:
|
||||
parts.append("<ul>")
|
||||
for a in e.achievements:
|
||||
if a:
|
||||
parts.append(f"<li>{a}</li>")
|
||||
parts.append("</ul>")
|
||||
if e.tech:
|
||||
parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
|
||||
parts.append("</div>")
|
||||
else:
|
||||
parts.append("<p class='meta'>Нет записей.</p>")
|
||||
|
||||
# Skills
|
||||
parts.append("<h2>Навыки</h2>")
|
||||
skills = data["skills_map"]
|
||||
if skills:
|
||||
for g in skills:
|
||||
if g.items:
|
||||
parts.append(f"<p><strong>{g.group}:</strong> {', '.join([i for i in g.items if i])}</p>")
|
||||
else:
|
||||
parts.append("<p class='meta'>Нет данных.</p>")
|
||||
|
||||
parts.append("</body></html>")
|
||||
|
||||
html = "".join(parts)
|
||||
pdf = HTML(string=html).write_pdf()
|
||||
out = BytesIO(pdf)
|
||||
out.seek(0)
|
||||
return out
|
||||
|
||||
|
||||
Reference in New Issue
Block a user