add linters

This commit is contained in:
Pavel Sobolev
2025-11-13 01:32:17 +03:00
parent c4bb087aaf
commit d5ff05abdb
28 changed files with 2070 additions and 331 deletions

View File

@@ -0,0 +1 @@
"""Пакет приложения `cv` (модели, админка, сервисы выгрузки резюме)."""

View File

@@ -1,17 +1,41 @@
"""Админские классы Django для управления моделями резюме."""
from django.contrib import admin
from .models import Profile, Experience, SkillGroup
from .models import Experience, Profile, SkillGroup
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('role', 'full_name', 'gender')
list_display_links = ('full_name','role',)
"""Админка для модели профиля."""
list_display = ("role", "full_name", "gender")
list_display_links = (
"full_name",
"role",
)
@admin.register(Experience)
class ExperienceAdmin(admin.ModelAdmin):
list_display = ('profile', 'company', 'start_date', 'end_date')
list_display_links = ('profile', 'company',)
"""Админка для модели опыта работы."""
list_display = ("profile", "company", "start_date", "end_date")
list_display_links = (
"profile",
"company",
)
@admin.register(SkillGroup)
class SkillGroupAdmin(admin.ModelAdmin):
list_display = ('profile', 'group',)
list_display_links = ('profile', 'group',)
"""Админка для модели групп навыков."""
list_display = (
"profile",
"group",
)
list_display_links = (
"profile",
"group",
)

View File

@@ -1,6 +1,10 @@
"""Конфигурация приложения `cv` для Django."""
from django.apps import AppConfig
class CvConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'cv'
"""Регистрация приложения и базовые настройки."""
default_auto_field = "django.db.models.BigAutoField"
name = "cv"

View File

@@ -1,3 +1,4 @@
"""Начальная миграция приложения `cv` (автогенерируемая Django)."""
# Generated by Django 5.2.8 on 2025-11-11 18:36
import django.db.models.deletion
@@ -5,60 +6,94 @@ from django.db import migrations, models
class Migration(migrations.Migration):
"""Автогенерируемый класс миграции для создания базовых моделей."""
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Profile',
name="Profile",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=200)),
('role', models.CharField(max_length=120)),
('gender', models.CharField(choices=[('male', 'Мужчина'), ('female', 'Женщина')], max_length=10)),
('summary', models.TextField()),
('location', models.CharField(max_length=120)),
('languages', models.JSONField(default=list)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(max_length=20)),
('telegram', models.CharField(max_length=40)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("full_name", models.CharField(max_length=200)),
("role", models.CharField(max_length=120)),
(
"gender",
models.CharField(
choices=[("male", "Мужчина"), ("female", "Женщина")], max_length=10
),
),
("summary", models.TextField()),
("location", models.CharField(max_length=120)),
("languages", models.JSONField(default=list)),
("email", models.EmailField(max_length=254)),
("phone", models.CharField(max_length=20)),
("telegram", models.CharField(max_length=40)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'profile',
"db_table": "profile",
},
),
migrations.CreateModel(
name='Experience',
name="Experience",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company', models.CharField(max_length=200)),
('start_date', models.DateField()),
('end_date', models.DateField(blank=True, null=True)),
('summary', models.TextField()),
('achievements', models.JSONField(default=list)),
('tech', models.JSONField(default=list)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experience', to='cv.profile')),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("company", models.CharField(max_length=200)),
("start_date", models.DateField()),
("end_date", models.DateField(blank=True, null=True)),
("summary", models.TextField()),
("achievements", models.JSONField(default=list)),
("tech", models.JSONField(default=list)),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="experience",
to="cv.profile",
),
),
],
options={
'db_table': 'experience',
'ordering': ['-start_date'],
"db_table": "experience",
"ordering": ["-start_date"],
},
),
migrations.CreateModel(
name='SkillGroup',
name="SkillGroup",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group', models.CharField(max_length=100)),
('items', models.JSONField(default=list)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='skills_map', to='cv.profile')),
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("group", models.CharField(max_length=100)),
("items", models.JSONField(default=list)),
(
"profile",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="skills_map",
to="cv.profile",
),
),
],
options={
'db_table': 'skill_group',
"db_table": "skill_group",
},
),
]

View File

@@ -1,3 +1,4 @@
"""Миграция настроек verbose_name и полей моделей приложения `cv`."""
# Generated by Django 5.2.8 on 2025-11-12 18:24
import django.db.models.deletion
@@ -5,137 +6,156 @@ from django.db import migrations, models
class Migration(migrations.Migration):
"""Автогенерируемый класс миграции с изменениями опций и полей моделей."""
dependencies = [
('cv', '0001_initial'),
("cv", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name='experience',
options={'ordering': ['-start_date'], 'verbose_name': 'Опыт работы', 'verbose_name_plural': 'Опыт работы'},
name="experience",
options={
"ordering": ["-start_date"],
"verbose_name": "Опыт работы",
"verbose_name_plural": "Опыт работы",
},
),
migrations.AlterModelOptions(
name='profile',
options={'verbose_name': 'Профиль', 'verbose_name_plural': 'Профили'},
name="profile",
options={"verbose_name": "Профиль", "verbose_name_plural": "Профили"},
),
migrations.AlterModelOptions(
name='skillgroup',
options={'verbose_name': 'Группа навыков', 'verbose_name_plural': 'Группы навыков'},
name="skillgroup",
options={"verbose_name": "Группа навыков", "verbose_name_plural": "Группы навыков"},
),
migrations.AddField(
model_name='profile',
name='git',
field=models.URLField(blank=True, null=True, verbose_name='Git'),
model_name="profile",
name="git",
field=models.URLField(blank=True, null=True, verbose_name="Git"),
),
migrations.AddField(
model_name='profile',
name='photo',
field=models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото'),
model_name="profile",
name="photo",
field=models.ImageField(blank=True, null=True, upload_to="", verbose_name="Фото"),
),
migrations.AlterField(
model_name='experience',
name='achievements',
field=models.JSONField(default=list, verbose_name='Достижения'),
model_name="experience",
name="achievements",
field=models.JSONField(default=list, verbose_name="Достижения"),
),
migrations.AlterField(
model_name='experience',
name='company',
field=models.CharField(max_length=200, verbose_name='Компания'),
model_name="experience",
name="company",
field=models.CharField(max_length=200, verbose_name="Компания"),
),
migrations.AlterField(
model_name='experience',
name='end_date',
field=models.DateField(blank=True, null=True, verbose_name='Дата окончания'),
model_name="experience",
name="end_date",
field=models.DateField(blank=True, null=True, verbose_name="Дата окончания"),
),
migrations.AlterField(
model_name='experience',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experience', to='cv.profile', verbose_name='Профиль'),
model_name="experience",
name="profile",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="experience",
to="cv.profile",
verbose_name="Профиль",
),
),
migrations.AlterField(
model_name='experience',
name='start_date',
field=models.DateField(verbose_name='Дата начала'),
model_name="experience",
name="start_date",
field=models.DateField(verbose_name="Дата начала"),
),
migrations.AlterField(
model_name='experience',
name='summary',
field=models.TextField(verbose_name='Краткое описание'),
model_name="experience",
name="summary",
field=models.TextField(verbose_name="Краткое описание"),
),
migrations.AlterField(
model_name='experience',
name='tech',
field=models.JSONField(default=list, verbose_name='Технологии'),
model_name="experience",
name="tech",
field=models.JSONField(default=list, verbose_name="Технологии"),
),
migrations.AlterField(
model_name='profile',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'),
model_name="profile",
name="created_at",
field=models.DateTimeField(auto_now_add=True, verbose_name="Дата создания"),
),
migrations.AlterField(
model_name='profile',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
model_name="profile",
name="email",
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="Email"),
),
migrations.AlterField(
model_name='profile',
name='full_name',
field=models.CharField(max_length=200, verbose_name='ФИО'),
model_name="profile",
name="full_name",
field=models.CharField(max_length=200, verbose_name="ФИО"),
),
migrations.AlterField(
model_name='profile',
name='gender',
field=models.CharField(choices=[('male', 'Мужской'), ('female', 'Женский')], max_length=10, verbose_name='Пол'),
model_name="profile",
name="gender",
field=models.CharField(
choices=[("male", "Мужской"), ("female", "Женский")],
max_length=10,
verbose_name="Пол",
),
),
migrations.AlterField(
model_name='profile',
name='languages',
field=models.JSONField(default=list, verbose_name='Языки'),
model_name="profile",
name="languages",
field=models.JSONField(default=list, verbose_name="Языки"),
),
migrations.AlterField(
model_name='profile',
name='location',
field=models.CharField(max_length=120, verbose_name='Местоположение'),
model_name="profile",
name="location",
field=models.CharField(max_length=120, verbose_name="Местоположение"),
),
migrations.AlterField(
model_name='profile',
name='phone',
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон'),
model_name="profile",
name="phone",
field=models.CharField(blank=True, max_length=20, null=True, verbose_name="Телефон"),
),
migrations.AlterField(
model_name='profile',
name='role',
field=models.CharField(max_length=120, verbose_name='Роль'),
model_name="profile",
name="role",
field=models.CharField(max_length=120, verbose_name="Роль"),
),
migrations.AlterField(
model_name='profile',
name='summary',
field=models.TextField(verbose_name='Краткое описание'),
model_name="profile",
name="summary",
field=models.TextField(verbose_name="Краткое описание"),
),
migrations.AlterField(
model_name='profile',
name='telegram',
field=models.CharField(blank=True, max_length=40, null=True, verbose_name='Telegram'),
model_name="profile",
name="telegram",
field=models.CharField(blank=True, max_length=40, null=True, verbose_name="Telegram"),
),
migrations.AlterField(
model_name='profile',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Дата обновления'),
model_name="profile",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
),
migrations.AlterField(
model_name='skillgroup',
name='group',
field=models.CharField(max_length=100, verbose_name='Группа'),
model_name="skillgroup",
name="group",
field=models.CharField(max_length=100, verbose_name="Группа"),
),
migrations.AlterField(
model_name='skillgroup',
name='items',
field=models.JSONField(default=list, verbose_name='Элементы'),
model_name="skillgroup",
name="items",
field=models.JSONField(default=list, verbose_name="Элементы"),
),
migrations.AlterField(
model_name='skillgroup',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='skills_map', to='cv.profile', verbose_name='Профиль'),
model_name="skillgroup",
name="profile",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="skills_map",
to="cv.profile",
verbose_name="Профиль",
),
),
]

View File

@@ -0,0 +1 @@
"""Пакет миграций приложения `cv`."""

View File

@@ -1,9 +1,14 @@
"""Модели профиля резюме, опыта и групп навыков."""
from django.db import models
class Profile(models.Model):
"""Профиль пользователя с контактами и базовой информацией."""
GENDER_CHOICES = [
('male', 'Мужской'),
('female', 'Женский'),
("male", "Мужской"),
("female", "Женский"),
]
full_name = models.CharField(max_length=200, verbose_name="ФИО")
@@ -13,26 +18,32 @@ class Profile(models.Model):
location = models.CharField(max_length=120, verbose_name="Местоположение")
languages = models.JSONField(default=list, verbose_name="Языки")
email = models.EmailField(null=True, blank=True, verbose_name="Email")
phone = models.CharField(max_length=20, null=True, blank=True, verbose_name="Телефон")
phone = models.CharField(max_length=20, null=True, blank=True, verbose_name="Телефон")
telegram = models.CharField(max_length=40, null=True, blank=True, verbose_name="Telegram")
git = models.URLField(null=True, blank=True, verbose_name="Git")
photo = models.ImageField(upload_to='photos/', null=True, blank=True, verbose_name="Фото")
photo = models.ImageField(upload_to="photos/", null=True, blank=True, verbose_name="Фото")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
"""Метаданные модели профиля для Django админки и БД."""
db_table = "profile"
verbose_name = "Профиль"
verbose_name_plural = "Профили"
def __str__(self):
def __str__(self) -> str:
"""Строковое представление профиля."""
return self.full_name
class Experience(models.Model):
"""Запись опыта работы, связанная с профилем."""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="experience", verbose_name="Профиль")
profile = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="experience", verbose_name="Профиль"
)
company = models.CharField(max_length=200, verbose_name="Компания")
start_date = models.DateField(verbose_name="Дата начала")
@@ -42,26 +53,34 @@ class Experience(models.Model):
tech = models.JSONField(default=list, verbose_name="Технологии")
class Meta:
"""Метаданные модели опыта работы: сортировка и отображение."""
db_table = "experience"
ordering = ["-start_date"]
verbose_name = "Опыт работы"
verbose_name_plural = "Опыт работы"
def __str__(self):
def __str__(self) -> str:
"""Строковое представление записи опыта работы."""
return f"{self.profile.full_name} - {self.company}"
class SkillGroup(models.Model):
"""Группы навыков с массивом значений, как в JSON."""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="skills_map", verbose_name="Профиль")
profile = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="skills_map", verbose_name="Профиль"
)
group = models.CharField(max_length=100, verbose_name="Группа")
items = models.JSONField(default=list, verbose_name="Элементы")
class Meta:
"""Метаданные модели группы навыков."""
db_table = "skill_group"
verbose_name = "Группа навыков"
verbose_name_plural = "Группы навыков"
def __str__(self):
return self.group
def __str__(self) -> str:
"""Строковое представление группы навыков."""
return self.group

View File

@@ -1,13 +1,30 @@
"""Базовые протоколы и сериализаторы для рендереров резюме.
Содержит `ProfileSerializer` для подготовки данных профиля к выводу
и протокол `Renderer` с контрактом метода `render`.
"""
from __future__ import annotations
from io import BytesIO
from typing import Any, Dict, Protocol
from typing import Any, Protocol
class ProfileSerializer:
"""Сериализатор данных профиля для рендереров."""
"""Сериализатор данных профиля для рендереров.
def serialize(self, profile) -> Dict[str, Any]:
Готовит словарь с данными, необходимыми для HTML/PDF/DOCX рендеринга.
"""
def serialize(self, profile: Any) -> dict[str, Any]:
"""Собрать слепок профиля для рендеринга.
Args:
profile: Экземпляр модели профиля.
Returns:
dict[str, Any]: Сериализованные поля профиля, контактов, опыта и навыков.
"""
return {
"full_name": profile.full_name,
"role": getattr(profile, "role", ""),
@@ -25,6 +42,8 @@ class ProfileSerializer:
class Renderer(Protocol):
def render(self, profile) -> BytesIO: ...
"""Протокол рендерера документов с методом `render`."""
def render(self, profile: Any) -> BytesIO:
"""Сгенерировать бинарный документ по профилю."""
...

View File

@@ -1,21 +1,43 @@
"""Рендеринг резюме в формат DOCX.
Класс `DocxRenderer` формирует документ с заголовками, контактами, опытом
и навыками на основе сериализованных данных профиля.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from io import BytesIO
from typing import Any
from docx import Document
from docx.shared import Pt, RGBColor
from docx import Document as new_document
from docx.document import Document as DocxDocument
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt, RGBColor
from cv.services.dowload.pdf import ProfileSerializer
from cv.services.dowload.base import ProfileSerializer
from resume.utils.logging import configure_root_logger
_root = logging.getLogger()
if not _root.handlers:
configure_root_logger()
logger = logging.getLogger(__name__)
class DocxRenderer:
def __init__(self, ):
"""Создает DOCX-документ на основе данных профиля."""
def __init__(
self,
) -> None:
"""Инициализирует сериализатор документов DOCX."""
self.serializer = ProfileSerializer()
@staticmethod
def _add_divider(document: Document) -> None:
def _add_divider(document: DocxDocument) -> None:
"""Добавить горизонтальный разделитель как границу параграфа."""
p = document.add_paragraph()
fmt = p.paragraph_format
fmt.space_before = Pt(6)
@@ -32,38 +54,45 @@ class DocxRenderer:
pPr.append(pBdr)
@staticmethod
def _month_year(dt) -> str:
def _month_year(dt: Any) -> str:
"""Вернуть строку месяца и года на русском для даты."""
ru_months = {
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
5: "май", 6: "июнь", 7: "июль", 8: "август",
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь",
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()
# Заголовок
def _append_header(self, doc: DocxDocument, data: dict[str, Any]) -> None:
"""Добавить заголовок и краткие сведения."""
doc.core_properties.title = data["full_name"]
if data["role"]:
if data.get("role"):
doc.core_properties.subject = data["role"]
doc.add_heading(data["full_name"], level=0)
if data["role"]:
if data.get("role"):
doc.add_paragraph(data["role"])
if data["summary"]:
if data.get("summary"):
doc.add_paragraph(data["summary"])
meta_parts: list[str] = []
if data["location"]:
if data.get("location"):
meta_parts.append(data["location"])
if data["languages"]:
if data.get("languages"):
meta_parts.append("Языки: " + ", ".join(data["languages"]))
if meta_parts:
doc.add_paragraph("".join(meta_parts))
# Контакты
def _append_contacts(self, doc: DocxDocument, contacts: dict[str, Any]) -> None:
"""Добавить секцию контактов."""
doc.add_heading("Контакты", level=1)
contacts = data["contacts"]
if contacts.get("email"):
doc.add_paragraph(f"Email: {contacts['email']}")
if contacts.get("phone"):
@@ -71,12 +100,17 @@ class DocxRenderer:
if contacts.get("telegram"):
doc.add_paragraph(f"Telegram: {contacts['telegram']}")
# Опыт
def _append_experience(self, doc: DocxDocument, experience: Sequence[Any] | None) -> None:
"""Добавить опыт работы."""
doc.add_heading("Опыт работы", level=1)
for i, e in enumerate(data["experience"]):
for i, e in enumerate(experience or []):
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 'настоящее время')}"
start = self._month_year(e.start_date) if getattr(e, "start_date", None) else ""
end = (
self._month_year(e.end_date) if getattr(e, "end_date", None) else "настоящее время"
)
period = f"{start}{end}"
p_company = doc.add_paragraph()
run_company = p_company.add_run(e.company)
run_company.bold = True
@@ -87,19 +121,42 @@ class DocxRenderer:
r.font.color.rgb = RGBColor(0x99, 0xA2, 0xB2)
if e.summary:
doc.add_paragraph(e.summary)
for a in (e.achievements or []):
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]))
# Навыки
def _append_skills(self, doc: DocxDocument, skills: Sequence[Any] | None) -> None:
"""Добавить навыки."""
doc.add_heading("Навыки", level=1)
for g in data["skills_map"]:
for g in skills or []:
if g.items:
doc.add_paragraph(f"{g.group}: " + ", ".join([i for i in g.items if i]))
def render(self, profile: Any) -> BytesIO:
"""Сформировать DOCX документ по профилю.
Args:
profile: Объект профиля.
Returns:
BytesIO: Буфер с содержимым DOCX.
"""
logger.info("Старт рендеринга DOCX для профиля")
data = self.serializer.serialize(profile)
buf = BytesIO()
doc: DocxDocument = new_document()
# Заголовок
self._append_header(doc, data)
# Контакты
self._append_contacts(doc, data.get("contacts", {}))
# Опыт
self._append_experience(doc, data.get("experience"))
# Навыки
self._append_skills(doc, data.get("skills_map"))
doc.save(buf)
buf.seek(0)
logger.info("DOCX сгенерирован: %s байт", buf.getbuffer().nbytes)
return buf

View File

@@ -1,107 +1,199 @@
"""Рендеринг резюме в PDF на базе WeasyPrint.
Модуль предоставляет класс `PdfRenderer` для генерации минималистичного HTML
и конвертации его в PDF. Включает базовый печатный CSS и сериализацию профиля.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from io import BytesIO
from typing import Any
from weasyprint import HTML
from cv.models import Profile
from cv.services.dowload.base import ProfileSerializer
from resume.utils.logging import configure_root_logger
# Инициализация логирования с RichHandler (формат и уровень по правилам проекта)
_root = logging.getLogger()
if not _root.handlers:
configure_root_logger()
logger = logging.getLogger(__name__)
class PdfRenderer:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF."""
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.
def __init__(self):
# template не используется, оставлен для совместимости интерфейса
Методы класса генерируют минимальный печатный HTML и превращают его
в PDF с помощью WeasyPrint.
"""
def __init__(self) -> None:
"""Инициализирует сериализатор и части HTML."""
self.serializer = ProfileSerializer()
self.parts: list[str] = []
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"]:
def _build_print_css(self) -> str:
"""Вернуть минимальный CSS для печатного PDF.
Returns:
str: Строка CSS-правил, совместимых с WeasyPrint.
"""
css_rules = """
@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; }
""".strip()
return css_rules
def _append_head(self, full_name: str) -> None:
"""Добавить секцию head с CSS.
Args:
full_name (str): Полное имя для тега <title>.
"""
self.parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
self.parts.append(f"<title>{full_name} — Резюме (PDF)</title>")
self.parts.append("<style>")
self.parts.append(self._build_print_css())
self.parts.append("</style></head><body>")
def _append_header_section(self, data: dict[str, Any]) -> None:
"""Добавить шапку с именем, ролью, summary и метками."""
self.parts.append(f"<h1>{data['full_name']}</h1>")
if data.get("role"):
self.parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
if data.get("summary"):
self.parts.append(f"<p>{data['summary']}</p>")
meta_tags: list[str] = []
if data.get("location"):
meta_tags.append(f"<span class='tag'>{data['location']}</span>")
if data["languages"]:
if data.get("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>")
self.parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
# Contacts
contacts = data["contacts"]
parts.append("<h2>Контакты</h2><p>")
def _append_contacts_section(self, contacts: dict[str, Any]) -> None:
"""Добавить секцию контактов."""
self.parts.append("<h2>Контакты</h2><p>")
if contacts.get("email"):
parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
self.parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
if contacts.get("phone"):
parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
self.parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
if contacts.get("telegram"):
parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
parts.append("</p>")
self.parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
self.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>")
def _append_experience_section(self, experience: Sequence[Any] | None) -> None:
"""Добавить секцию опыта работы."""
self.parts.append("<h2>Опыт работы</h2>")
if experience:
for e in experience:
self.parts.append("<div class='item'>")
self.parts.append(f"<div class='item-title'>{e.company}</div>")
period = ""
if getattr(e, 'start_date', None):
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>")
period += (
e.end_date.strftime("%B %Y")
if getattr(e, "end_date", None)
else "настоящее время"
)
self.parts.append(f"<div class='item-period'>{period}</div>")
if e.summary:
parts.append(f"<p>{e.summary}</p>")
self.parts.append(f"<p>{e.summary}</p>")
if e.achievements:
parts.append("<ul>")
self.parts.append("<ul>")
for a in e.achievements:
if a:
parts.append(f"<li>{a}</li>")
parts.append("</ul>")
self.parts.append(f"<li>{a}</li>")
self.parts.append("</ul>")
if e.tech:
parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
parts.append("</div>")
self.parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
self.parts.append("</div>")
else:
parts.append("<p class='meta'>Нет записей.</p>")
self.parts.append("<p class='meta'>Нет записей.</p>")
# Skills
parts.append("<h2>Навыки</h2>")
skills = data["skills_map"]
def _append_skills_section(self, skills: Sequence[Any] | None) -> None:
"""Добавить секцию навыков."""
self.parts.append("<h2>Навыки</h2>")
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>")
self.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>")
self.parts.append("<p class='meta'>Нет данных.</p>")
parts.append("</body></html>")
def render(self, profile: Profile) -> BytesIO:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.
html = "".join(parts)
Args:
profile (Profile): Профиль для сериализации.
Returns:
BytesIO: PDF-файл.
"""
logger.info("Старт рендеринга PDF для профиля")
data = self.serializer.serialize(profile)
if not data or "full_name" not in data or not data["full_name"]:
logger.error("Отсутствует обязательное поле full_name после сериализации")
raise ValueError("Невозможно сформировать PDF: отсутствует full_name")
logger.info("Сериализация профиля завершена, подготовка HTML")
# Head + Header
self._append_head(data["full_name"])
self._append_header_section(data)
# Contacts
self._append_contacts_section(data["contacts"])
# Experience
self._append_experience_section(data["experience"])
# Skills
self._append_skills_section(data["skills_map"])
self.parts.append("</body></html>")
logger.info("HTML подготовлен, генерация PDF через WeasyPrint")
html = "".join(self.parts)
pdf = HTML(string=html).write_pdf()
out = BytesIO(pdf)
out.seek(0)
logger.info("PDF сгенерирован: %s байт", len(pdf))
return out

View File

@@ -35,8 +35,13 @@
</div>
<div class="hero__content">
<h2>Привет, я {{ profile.full_name }}</h2>
{% if profile.role %}<p class="lead">{{ profile.role }} • {{ profile.location }}</p>{% endif %}
<p class="lead" style="margin-top:12px">{{ profile.summary }}</p>
{% if profile.role %}
<div class="hero-meta mt-8">
<span class="chip">{{ profile.role }}</span>
{% if profile.location %}<span class="chip muted">{{ profile.location }}</span>{% endif %}
</div>
{% endif %}
<p class="lead mt-12">{{ profile.summary }}</p>
</div>
</section>
@@ -44,17 +49,17 @@
<div>
<section id="experience" class="card">
<h3>Опыт работы</h3>
<div class="timeline" style="margin-top:10px">
<div class="timeline mt-10">
{% for job in profile.experience.all %}
<article class="job">
<div class="company"><span style="opacity:.9">{{ job.company }}</span></div>
<div class="company"><span class="company-name">{{ job.company }}</span></div>
<div class="period">
{% with sd=job.start_date ed=job.end_date %}
{% if sd %}{{ sd|date:"F Y" }}{% endif %}{% if ed %} — {{ ed|date:"F Y" }}{% else %} — настоящее время
{% endif %}
{% endwith %}
</div>
{% if job.summary %}<div style="margin-top:8px;color:var(--muted)">{{ job.summary }}</div>{% endif %}
{% if job.summary %}<div class="job-summary">{{ job.summary }}</div>{% endif %}
{% if job.achievements %}
<ul>
{% for a in job.achievements %}
@@ -73,11 +78,11 @@
<aside>
<section id="skills" class="card">
<h3>Навыки</h3>
<div style="margin-top:10px">
<div class="skills mt-10">
{% for group in profile.skills_map.all %}
<div style="margin-bottom:10px">
<div style="font-weight:700">{{ group.group }}</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">
<div class="skill-group mb-10">
<div class="skill-title">{{ group.group }}</div>
<div class="skill-items mt-8">
{% for s in group.items %}<span class="pill">{{ s }}</span>{% endfor %}
</div>
</div>
@@ -85,9 +90,9 @@
</div>
</section>
<section id="contacts" class="card" style="margin-top:18px">
<section id="contacts" class="card contacts mt-18">
<h3>Контакты</h3>
<div style="margin-top:10px;display:flex;flex-direction:column;gap:10px">
<div class="contacts-list mt-10">
{% if profile.email %}
<div>
<strong>Email:</strong>

View File

@@ -1,3 +1,3 @@
from django.test import TestCase
"""Тесты приложения `cv`."""
# Create your tests here.

View File

@@ -1,5 +1,8 @@
"""Маршруты приложения `cv`."""
from django.urls import path
from .views import ProfileView, DownloadDocxView, DownloadPdfView
from .views import DownloadDocxView, DownloadPdfView, ProfileView
app_name = "cv"
@@ -7,4 +10,4 @@ urlpatterns = [
path("", ProfileView.as_view(), name="profile"),
path("download/resume.docx", DownloadDocxView.as_view(), name="resume-docx"),
path("download/resume.pdf", DownloadPdfView.as_view(), name="resume-pdf"),
]
]

View File

@@ -1,14 +1,30 @@
from django.http import Http404, FileResponse
"""Представления (views) приложения `cv`."""
from typing import Any
from django.http import FileResponse, Http404, HttpRequest
from django.views.generic.base import TemplateView, View
from cv.models import Profile
from cv.services.dowload.docx import DocxRenderer
from cv.services.dowload.pdf import PdfRenderer
class ProfileView(TemplateView):
template_name = 'index.html'
"""Отображение профиля на главной странице."""
template_name = "index.html"
extra_context = {}
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
"""Собрать контекст для шаблона.
Args:
**kwargs: Дополнительные параметры контекста.
Returns:
dict: Словарь контекста для рендеринга шаблона.
"""
kwargs.setdefault("view", self)
extra_context = self._get_extra_context()
self.extra_context.update(extra_context)
@@ -16,19 +32,42 @@ class ProfileView(TemplateView):
kwargs.update(self.extra_context)
return kwargs
def _get_extra_context(self):
profile = Profile.objects.prefetch_related('experience', 'skills_map').first()
def _get_extra_context(self) -> dict[str, Any]:
"""Получить дополнительные данные для контекста (профиль и связанные сущности).
Returns:
dict: Дополнительные пары ключ/значение для контекста.
Raises:
Http404: Если профиль не найден.
"""
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile:
raise Http404("Профиль не найден")
return {
'profile': profile,
"profile": profile,
}
class DownloadDocxView(View):
"""Скачать резюме в формате DOCX."""
_docx = DocxRenderer()
def get(self, request, *args, **kwargs):
def get(self, _request: HttpRequest, *_args: Any, **_kwargs: Any) -> FileResponse:
"""Вернуть файл DOCX с содержимым резюме.
Args:
request: HTTPзапрос.
*args: Позиционные аргументы.
**kwargs: Именованные аргументы.
Returns:
FileResponse: Ответ c приложением DOCX.
Raises:
Http404: Если профиль не найден.
"""
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile:
raise Http404("Профиль не найден")
@@ -48,9 +87,24 @@ class DownloadDocxView(View):
class DownloadPdfView(View):
"""Скачать резюме в формате PDF."""
_pdf = PdfRenderer()
def get(self, request, *args, **kwargs):
def get(self, _request: HttpRequest, *_args: Any, **_kwargs: Any) -> FileResponse:
"""Вернуть файл PDF с содержимым резюме.
Args:
request: HTTPзапрос.
*args: Позиционные аргументы.
**kwargs: Именованные аргументы.
Returns:
FileResponse: Ответ c приложением PDF.
Raises:
Http404: Если профиль не найден.
"""
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile:
raise Http404("Профиль не найден")
@@ -65,4 +119,4 @@ class DownloadPdfView(View):
content_type="application/pdf",
)
resp["Cache-Control"] = "no-store"
return resp
return resp