-
{% for a in job.achievements %}
@@ -73,11 +78,11 @@
diff --git a/.gitignore b/.gitignore index edf6816..2ae4015 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ static .venv media staticfiles -.env \ No newline at end of file +.env +.ruff_cache +.mypy_cache +.pytest_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..71fd8ee --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +repos: +- repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: + - "django>=5.2.8,<6.0.0" + - "python-dotenv>=1.0.1,<2.0.0" + - django-stubs + - djangorestframework-stubs + - pandas-stubs + +- repo: https://github.com/PyCQA/bandit + rev: 1.7.9 + hooks: + - id: bandit + args: ["-r",".","-x",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules"] + additional_dependencies: + - "python-dotenv>=1.0.1,<2.0.0" + pass_filenames: false + +- repo: https://github.com/pypa/pip-audit + rev: v2.7.3 + hooks: + - id: pip-audit + args: ["--progress-spinner=off"] + pass_filenames: false + +- repo: https://github.com/jendrikseipp/vulture + rev: "v2.11" + hooks: + - id: vulture + args: [".","--exclude",".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules,**/migrations/**","--min-confidence","80"] \ No newline at end of file diff --git a/README.md b/README.md index 0274c07..a2e7f32 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,24 @@ PDF рендерится через WeasyPrint. Для Linux/WSL установ ### Данные Профиль хранится в БД (модель `Profile` + связанные `Experience`, `SkillGroup`). Наполнение — через админку/скрипты/миграции по вашему выбору. Страница читает данные напрямую из БД. +## От грязи +### Линтер/форматер +``` +poetry run ruff check . +poetry run ruff format . +``` +### Типы +``` +poetry run mypy . +``` +### Безопасность кода/зависимостей +``` +poetry run bandit -r cv resume -x .venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules +poetry run pip-audit +``` + +### Мёртвый код +``` +poetry run vulture cv resume --exclude ".venv,venv,build,dist,.tox,.mypy_cache,.ruff_cache,node_modules,/migrations/" --min-confidence 80 +``` \ No newline at end of file diff --git a/cv/__init__.py b/cv/__init__.py index e69de29..fabe0e2 100644 --- a/cv/__init__.py +++ b/cv/__init__.py @@ -0,0 +1 @@ +"""Пакет приложения `cv` (модели, админка, сервисы выгрузки резюме).""" diff --git a/cv/admin.py b/cv/admin.py index c4ad354..dfdeb0d 100644 --- a/cv/admin.py +++ b/cv/admin.py @@ -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',) \ No newline at end of file + """Админка для модели групп навыков.""" + + list_display = ( + "profile", + "group", + ) + list_display_links = ( + "profile", + "group", + ) diff --git a/cv/apps.py b/cv/apps.py index c84db0d..b888a71 100644 --- a/cv/apps.py +++ b/cv/apps.py @@ -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" diff --git a/cv/migrations/0001_initial.py b/cv/migrations/0001_initial.py index fd2379f..8beeda4 100644 --- a/cv/migrations/0001_initial.py +++ b/cv/migrations/0001_initial.py @@ -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", }, ), ] diff --git a/cv/migrations/0002_alter_experience_options_alter_profile_options_and_more.py b/cv/migrations/0002_alter_experience_options_alter_profile_options_and_more.py index a04b9dd..afed27d 100644 --- a/cv/migrations/0002_alter_experience_options_alter_profile_options_and_more.py +++ b/cv/migrations/0002_alter_experience_options_alter_profile_options_and_more.py @@ -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="Профиль", + ), ), ] diff --git a/cv/migrations/__init__.py b/cv/migrations/__init__.py index e69de29..12cc4da 100644 --- a/cv/migrations/__init__.py +++ b/cv/migrations/__init__.py @@ -0,0 +1 @@ +"""Пакет миграций приложения `cv`.""" diff --git a/cv/models.py b/cv/models.py index 39dbb92..8df5a6f 100644 --- a/cv/models.py +++ b/cv/models.py @@ -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 \ No newline at end of file + def __str__(self) -> str: + """Строковое представление группы навыков.""" + return self.group diff --git a/cv/services/dowload/base.py b/cv/services/dowload/base.py index 44addcc..58db698 100644 --- a/cv/services/dowload/base.py +++ b/cv/services/dowload/base.py @@ -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: + """Сгенерировать бинарный документ по профилю.""" + ... diff --git a/cv/services/dowload/docx.py b/cv/services/dowload/docx.py index 97a315a..041a928 100644 --- a/cv/services/dowload/docx.py +++ b/cv/services/dowload/docx.py @@ -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 - diff --git a/cv/services/dowload/pdf.py b/cv/services/dowload/pdf.py index 37a59b9..ecb8c5d 100644 --- a/cv/services/dowload/pdf.py +++ b/cv/services/dowload/pdf.py @@ -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("
") - parts.append(f"{data['summary']}
") - 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): Полное имя для тега{data['summary']}
") + meta_tags: list[str] = [] + if data.get("location"): meta_tags.append(f"{data['location']}") - if data["languages"]: + if data.get("languages"): meta_tags.append(f"Языки: {', '.join(data['languages'])}") if meta_tags: - parts.append(f"") + self.parts.append(f"") - # Contacts - contacts = data["contacts"] - parts.append("") + def _append_contacts_section(self, contacts: dict[str, Any]) -> None: + """Добавить секцию контактов.""" + self.parts.append("
")
if contacts.get("email"):
- parts.append(f"Email: {contacts['email']}
")
+ self.parts.append(f"Email: {contacts['email']}
")
if contacts.get("phone"):
- parts.append(f"Телефон: {contacts['phone']}
")
+ self.parts.append(f"Телефон: {contacts['phone']}
")
if contacts.get("telegram"):
- parts.append(f"Telegram: {contacts['telegram']}")
- parts.append("
{e.summary}
") + self.parts.append(f"{e.summary}
") if e.achievements: - parts.append("{g.group}: {', '.join([i for i in g.items if i])}
") + self.parts.append( + f"{g.group}: {', '.join([i for i in g.items if i])}
" + ) else: - parts.append("") + self.parts.append("") - parts.append("") + 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("") + + 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 - - diff --git a/cv/templates/index.html b/cv/templates/index.html index 5082d06..e858d89 100644 --- a/cv/templates/index.html +++ b/cv/templates/index.html @@ -35,8 +35,13 @@{{ profile.role }} • {{ profile.location }}
{% endif %} -{{ profile.summary }}
+ {% if profile.role %} + + {% endif %} +{{ profile.summary }}