Compare commits

..

4 Commits

Author SHA1 Message Date
4914cae2b4 Merge pull request 'add tests' (#8) from RESUME-7 into master
Reviewed-on: #8
2025-11-13 18:32:07 +00:00
Pavel Sobolev
80911bd538 add tests 2025-11-13 21:30:45 +03:00
b506f55060 Merge pull request 'add linters' (#5) from RESUME-2 into master
Reviewed-on: #5
2025-11-12 22:35:09 +00:00
Pavel Sobolev
d5ff05abdb add linters 2025-11-13 01:33:00 +03:00
35 changed files with 2428 additions and 333 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ static
media media
staticfiles staticfiles
.env .env
.ruff_cache
.mypy_cache
.pytest_cache

49
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,49 @@
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,**/tests/**"]
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"]
- repo: local
hooks:
- id: pytest
name: pytest (unit+integration)
entry: poetry run pytest -q
language: system
pass_filenames: false
stages: [commit]

View File

@@ -27,4 +27,24 @@ PDF рендерится через WeasyPrint. Для Linux/WSL установ
### Данные ### Данные
Профиль хранится в БД (модель `Profile` + связанные `Experience`, `SkillGroup`). Наполнение — через админку/скрипты/миграции по вашему выбору. Страница читает данные напрямую из БД. Профиль хранится в БД (модель `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
```

View File

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

View File

@@ -1,17 +1,41 @@
"""Админские классы Django для управления моделями резюме."""
from django.contrib import admin from django.contrib import admin
from .models import Profile, Experience, SkillGroup
from .models import Experience, Profile, SkillGroup
@admin.register(Profile) @admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin): 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) @admin.register(Experience)
class ExperienceAdmin(admin.ModelAdmin): 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) @admin.register(SkillGroup)
class SkillGroupAdmin(admin.ModelAdmin): 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 from django.apps import AppConfig
class CvConfig(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 # Generated by Django 5.2.8 on 2025-11-11 18:36
import django.db.models.deletion import django.db.models.deletion
@@ -5,60 +6,94 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""Автогенерируемый класс миграции для создания базовых моделей."""
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Profile', name="Profile",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('full_name', models.CharField(max_length=200)), "id",
('role', models.CharField(max_length=120)), models.BigAutoField(
('gender', models.CharField(choices=[('male', 'Мужчина'), ('female', 'Женщина')], max_length=10)), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('summary', models.TextField()), ),
('location', models.CharField(max_length=120)), ),
('languages', models.JSONField(default=list)), ("full_name", models.CharField(max_length=200)),
('email', models.EmailField(max_length=254)), ("role", models.CharField(max_length=120)),
('phone', models.CharField(max_length=20)), (
('telegram', models.CharField(max_length=40)), "gender",
('created_at', models.DateTimeField(auto_now_add=True)), models.CharField(
('updated_at', models.DateTimeField(auto_now=True)), 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={ options={
'db_table': 'profile', "db_table": "profile",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Experience', name="Experience",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('company', models.CharField(max_length=200)), "id",
('start_date', models.DateField()), models.BigAutoField(
('end_date', models.DateField(blank=True, null=True)), auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
('summary', models.TextField()), ),
('achievements', models.JSONField(default=list)), ),
('tech', models.JSONField(default=list)), ("company", models.CharField(max_length=200)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experience', to='cv.profile')), ("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={ options={
'db_table': 'experience', "db_table": "experience",
'ordering': ['-start_date'], "ordering": ["-start_date"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SkillGroup', name="SkillGroup",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('group', models.CharField(max_length=100)), "id",
('items', models.JSONField(default=list)), models.BigAutoField(
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='skills_map', to='cv.profile')), 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={ 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 # Generated by Django 5.2.8 on 2025-11-12 18:24
import django.db.models.deletion import django.db.models.deletion
@@ -5,137 +6,156 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
"""Автогенерируемый класс миграции с изменениями опций и полей моделей."""
dependencies = [ dependencies = [
('cv', '0001_initial'), ("cv", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='experience', name="experience",
options={'ordering': ['-start_date'], 'verbose_name': 'Опыт работы', 'verbose_name_plural': 'Опыт работы'}, options={
"ordering": ["-start_date"],
"verbose_name": "Опыт работы",
"verbose_name_plural": "Опыт работы",
},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='profile', name="profile",
options={'verbose_name': 'Профиль', 'verbose_name_plural': 'Профили'}, options={"verbose_name": "Профиль", "verbose_name_plural": "Профили"},
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='skillgroup', name="skillgroup",
options={'verbose_name': 'Группа навыков', 'verbose_name_plural': 'Группы навыков'}, options={"verbose_name": "Группа навыков", "verbose_name_plural": "Группы навыков"},
), ),
migrations.AddField( migrations.AddField(
model_name='profile', model_name="profile",
name='git', name="git",
field=models.URLField(blank=True, null=True, verbose_name='Git'), field=models.URLField(blank=True, null=True, verbose_name="Git"),
), ),
migrations.AddField( migrations.AddField(
model_name='profile', model_name="profile",
name='photo', name="photo",
field=models.ImageField(blank=True, null=True, upload_to='', verbose_name='Фото'), field=models.ImageField(blank=True, null=True, upload_to="", verbose_name="Фото"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='achievements', name="achievements",
field=models.JSONField(default=list, verbose_name='Достижения'), field=models.JSONField(default=list, verbose_name="Достижения"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='company', name="company",
field=models.CharField(max_length=200, verbose_name='Компания'), field=models.CharField(max_length=200, verbose_name="Компания"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='end_date', name="end_date",
field=models.DateField(blank=True, null=True, verbose_name='Дата окончания'), field=models.DateField(blank=True, null=True, verbose_name="Дата окончания"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='profile', name="profile",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experience', to='cv.profile', verbose_name='Профиль'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="experience",
to="cv.profile",
verbose_name="Профиль",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='start_date', name="start_date",
field=models.DateField(verbose_name='Дата начала'), field=models.DateField(verbose_name="Дата начала"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='summary', name="summary",
field=models.TextField(verbose_name='Краткое описание'), field=models.TextField(verbose_name="Краткое описание"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='experience', model_name="experience",
name='tech', name="tech",
field=models.JSONField(default=list, verbose_name='Технологии'), field=models.JSONField(default=list, verbose_name="Технологии"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='created_at', name="created_at",
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'), field=models.DateTimeField(auto_now_add=True, verbose_name="Дата создания"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='email', name="email",
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'), field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="Email"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='full_name', name="full_name",
field=models.CharField(max_length=200, verbose_name='ФИО'), field=models.CharField(max_length=200, verbose_name="ФИО"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='gender', name="gender",
field=models.CharField(choices=[('male', 'Мужской'), ('female', 'Женский')], max_length=10, verbose_name='Пол'), field=models.CharField(
choices=[("male", "Мужской"), ("female", "Женский")],
max_length=10,
verbose_name="Пол",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='languages', name="languages",
field=models.JSONField(default=list, verbose_name='Языки'), field=models.JSONField(default=list, verbose_name="Языки"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='location', name="location",
field=models.CharField(max_length=120, verbose_name='Местоположение'), field=models.CharField(max_length=120, verbose_name="Местоположение"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='phone', name="phone",
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон'), field=models.CharField(blank=True, max_length=20, null=True, verbose_name="Телефон"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='role', name="role",
field=models.CharField(max_length=120, verbose_name='Роль'), field=models.CharField(max_length=120, verbose_name="Роль"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='summary', name="summary",
field=models.TextField(verbose_name='Краткое описание'), field=models.TextField(verbose_name="Краткое описание"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='telegram', name="telegram",
field=models.CharField(blank=True, max_length=40, null=True, verbose_name='Telegram'), field=models.CharField(blank=True, max_length=40, null=True, verbose_name="Telegram"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='profile', model_name="profile",
name='updated_at', name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name='Дата обновления'), field=models.DateTimeField(auto_now=True, verbose_name="Дата обновления"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='skillgroup', model_name="skillgroup",
name='group', name="group",
field=models.CharField(max_length=100, verbose_name='Группа'), field=models.CharField(max_length=100, verbose_name="Группа"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='skillgroup', model_name="skillgroup",
name='items', name="items",
field=models.JSONField(default=list, verbose_name='Элементы'), field=models.JSONField(default=list, verbose_name="Элементы"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='skillgroup', model_name="skillgroup",
name='profile', name="profile",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='skills_map', to='cv.profile', verbose_name='Профиль'), 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 from django.db import models
class Profile(models.Model): class Profile(models.Model):
"""Профиль пользователя с контактами и базовой информацией."""
GENDER_CHOICES = [ GENDER_CHOICES = [
('male', 'Мужской'), ("male", "Мужской"),
('female', 'Женский'), ("female", "Женский"),
] ]
full_name = models.CharField(max_length=200, verbose_name="ФИО") full_name = models.CharField(max_length=200, verbose_name="ФИО")
@@ -16,23 +21,29 @@ class Profile(models.Model):
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") telegram = models.CharField(max_length=40, null=True, blank=True, verbose_name="Telegram")
git = models.URLField(null=True, blank=True, verbose_name="Git") 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="Дата создания") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta: class Meta:
"""Метаданные модели профиля для Django админки и БД."""
db_table = "profile" db_table = "profile"
verbose_name = "Профиль" verbose_name = "Профиль"
verbose_name_plural = "Профили" verbose_name_plural = "Профили"
def __str__(self): def __str__(self) -> str:
"""Строковое представление профиля."""
return self.full_name return self.full_name
class Experience(models.Model): 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="Компания") company = models.CharField(max_length=200, verbose_name="Компания")
start_date = models.DateField(verbose_name="Дата начала") start_date = models.DateField(verbose_name="Дата начала")
@@ -42,26 +53,34 @@ class Experience(models.Model):
tech = models.JSONField(default=list, verbose_name="Технологии") tech = models.JSONField(default=list, verbose_name="Технологии")
class Meta: class Meta:
"""Метаданные модели опыта работы: сортировка и отображение."""
db_table = "experience" db_table = "experience"
ordering = ["-start_date"] ordering = ["-start_date"]
verbose_name = "Опыт работы" verbose_name = "Опыт работы"
verbose_name_plural = "Опыт работы" verbose_name_plural = "Опыт работы"
def __str__(self): def __str__(self) -> str:
"""Строковое представление записи опыта работы."""
return f"{self.profile.full_name} - {self.company}" return f"{self.profile.full_name} - {self.company}"
class SkillGroup(models.Model): class SkillGroup(models.Model):
"""Группы навыков с массивом значений, как в JSON.""" """Группы навыков с массивом значений, как в 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="Группа") group = models.CharField(max_length=100, verbose_name="Группа")
items = models.JSONField(default=list, verbose_name="Элементы") items = models.JSONField(default=list, verbose_name="Элементы")
class Meta: class Meta:
"""Метаданные модели группы навыков."""
db_table = "skill_group" db_table = "skill_group"
verbose_name = "Группа навыков" verbose_name = "Группа навыков"
verbose_name_plural = "Группы навыков" verbose_name_plural = "Группы навыков"
def __str__(self): def __str__(self) -> str:
"""Строковое представление группы навыков."""
return self.group return self.group

View File

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

View File

@@ -1,107 +1,199 @@
"""Рендеринг резюме в PDF на базе WeasyPrint.
Модуль предоставляет класс `PdfRenderer` для генерации минималистичного HTML
и конвертации его в PDF. Включает базовый печатный CSS и сериализацию профиля.
"""
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Sequence
from io import BytesIO from io import BytesIO
from typing import Any
from weasyprint import HTML from weasyprint import HTML
from cv.models import Profile
from cv.services.dowload.base import ProfileSerializer 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: class PdfRenderer:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF.""" """Формирует HTML на лету (без шаблона) и конвертирует в PDF.
def __init__(self): Методы класса генерируют минимальный печатный HTML и превращают его
# template не используется, оставлен для совместимости интерфейса в PDF с помощью WeasyPrint.
"""
def __init__(self) -> None:
"""Инициализирует сериализатор и части HTML."""
self.serializer = ProfileSerializer() self.serializer = ProfileSerializer()
self.parts: list[str] = []
def render(self, profile) -> BytesIO: def _build_print_css(self) -> str:
data = self.serializer.serialize(profile) """Вернуть минимальный CSS для печатного PDF.
# Минимальный, печатный HTML с безопасными стилями
parts = [] Returns:
parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>") str: Строка CSS-правил, совместимых с WeasyPrint.
parts.append(f"<title>{data['full_name']} — Резюме (PDF)</title>") """
parts.append(""" css_rules = """
<style>
@page { size: A4; margin: 18mm 16mm; } @page { size: A4; margin: 18mm 16mm; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #111; } body {
h1 { margin: 0 0 6mm 0; font-size: 20pt; } font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
h2 { margin: 8mm 0 3mm 0; font-size: 13pt; border-bottom: 1px solid #ccc; padding-bottom: 2mm; } Roboto, Arial, sans-serif;
p { margin: 0 0 3mm 0; line-height: 1.4; } 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; } .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; } .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 { margin: 0 0 5mm 0; }
.item-title { font-weight: 700; } .item-title { font-weight: 700; }
.item-period { color: #666; } .item-period { color: #666; }
ul { margin: 2mm 0 2mm 6mm; } ul { margin: 2mm 0 2mm 6mm; }
</style> """.strip()
</head><body>
""") return css_rules
# Header
parts.append(f"<h1>{data['full_name']}</h1>") def _append_head(self, full_name: str) -> None:
if data["role"]: """Добавить секцию head с CSS.
parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
if data["summary"]: Args:
parts.append(f"<p>{data['summary']}</p>") full_name (str): Полное имя для тега <title>.
meta_tags = [] """
if data["location"]: 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>") 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>") meta_tags.append(f"<span class='tag'>Языки: {', '.join(data['languages'])}</span>")
if meta_tags: 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 def _append_contacts_section(self, contacts: dict[str, Any]) -> None:
contacts = data["contacts"] """Добавить секцию контактов."""
parts.append("<h2>Контакты</h2><p>") self.parts.append("<h2>Контакты</h2><p>")
if contacts.get("email"): 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"): 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"): if contacts.get("telegram"):
parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}") self.parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
parts.append("</p>") self.parts.append("</p>")
# Experience def _append_experience_section(self, experience: Sequence[Any] | None) -> None:
parts.append("<h2>Опыт работы</h2>") """Добавить секцию опыта работы."""
exp = data["experience"] self.parts.append("<h2>Опыт работы</h2>")
if exp: if experience:
for e in exp: for e in experience:
parts.append("<div class='item'>") self.parts.append("<div class='item'>")
parts.append(f"<div class='item-title'>{e.company}</div>") self.parts.append(f"<div class='item-title'>{e.company}</div>")
period = "" period = ""
if getattr(e, 'start_date', None): if getattr(e, "start_date", None):
period += e.start_date.strftime("%B %Y") period += e.start_date.strftime("%B %Y")
period += "" period += ""
period += e.end_date.strftime("%B %Y") if getattr(e, 'end_date', None) else "настоящее время" period += (
parts.append(f"<div class='item-period'>{period}</div>") 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: if e.summary:
parts.append(f"<p>{e.summary}</p>") self.parts.append(f"<p>{e.summary}</p>")
if e.achievements: if e.achievements:
parts.append("<ul>") self.parts.append("<ul>")
for a in e.achievements: for a in e.achievements:
if a: if a:
parts.append(f"<li>{a}</li>") self.parts.append(f"<li>{a}</li>")
parts.append("</ul>") self.parts.append("</ul>")
if e.tech: if e.tech:
parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>") self.parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
parts.append("</div>") self.parts.append("</div>")
else: else:
parts.append("<p class='meta'>Нет записей.</p>") self.parts.append("<p class='meta'>Нет записей.</p>")
# Skills def _append_skills_section(self, skills: Sequence[Any] | None) -> None:
parts.append("<h2>Навыки</h2>") """Добавить секцию навыков."""
skills = data["skills_map"] self.parts.append("<h2>Навыки</h2>")
if skills: if skills:
for g in skills: for g in skills:
if g.items: 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: 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() pdf = HTML(string=html).write_pdf()
out = BytesIO(pdf) out = BytesIO(pdf)
out.seek(0) out.seek(0)
logger.info("PDF сгенерирован: %s байт", len(pdf))
return out return out

View File

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

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

1
cv/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Тестовый пакет приложения `cv`."""

View File

@@ -0,0 +1,74 @@
"""Тесты для `DocxRenderer` и сохранения DOCX в буфер."""
import logging
from io import BytesIO
import pytest
from cv.services.dowload.docx import DocxRenderer
from resume.utils.logging import configure_root_logger
logger = logging.getLogger(__name__)
configure_root_logger()
@pytest.fixture
def profile() -> object:
"""Минимальный фейковый профиль.
Returns:
object: Объект с обязательными атрибутами и менеджерами all().
"""
class P:
def __init__(self) -> None:
self.full_name = "Jane Doe"
self.experience = type("Q", (), {"all": lambda self: []})()
self.skills_map = type("Q", (), {"all": lambda self: []})()
return P()
def test_docx_render_unit(monkeypatch: pytest.MonkeyPatch, profile: object) -> None:
"""Юнит: замокаем serialize и save, проверим байты и единичный вызов.
Args:
monkeypatch (pytest.MonkeyPatch): Инструмент подмены атрибутов.
profile (object): Фейковый профиль.
"""
logger.info("Юнит-тест DOCX: проверяем проводку до save()")
fake_serialized = {
"full_name": "Jane Doe",
"role": "Dev",
"summary": "Summary",
"location": "Earth",
"languages": ["EN"],
"contacts": {"email": "jane@example.com", "phone": "", "telegram": ""},
"experience": [],
"skills_map": [],
}
expected_docx = b"PK\x03\x04FAKE-DOCX" # в юните можно вернуть фиктивные байты
def fake_serialize(_self: object, _p: object) -> dict:
"""Детерминированная сериализация."""
return fake_serialized
calls = {"save": 0}
def fake_save(self: object, stream: BytesIO) -> None: # noqa: D417
"""Подмена docx.document.Document.save — пишет ожидаемые байты.
Args:
stream (BytesIO): Целевой буфер.
"""
calls["save"] += 1
stream.write(expected_docx)
monkeypatch.setattr("cv.services.dowload.docx.ProfileSerializer.serialize", fake_serialize)
monkeypatch.setattr("docx.document.Document.save", fake_save, raising=True)
out = DocxRenderer().render(profile)
assert isinstance(out, BytesIO)
assert out.getvalue() == expected_docx
assert calls["save"] == 1

View File

@@ -0,0 +1,93 @@
"""Тесты для `PdfRenderer` и его HTML-вывода."""
import logging
from typing import Any, cast
import pytest
from cv.models import Profile
from cv.services.dowload.pdf import PdfRenderer
from resume.utils.logging import configure_root_logger
logger = logging.getLogger(__name__)
configure_root_logger()
@pytest.fixture
def profile() -> object:
"""Минимальный fake-профиль для тестов.
Returns:
object: Объект с обязательными атрибутами.
"""
class P:
def __init__(self) -> None:
self.full_name = "Jane Doe"
self.experience = type("Q", (), {"all": lambda self: []})()
self.skills_map = type("Q", (), {"all": lambda self: []})()
return P()
def test_render_success(monkeypatch: pytest.MonkeyPatch, profile: object) -> None:
"""Юнит: валидируем HTML и что write_pdf вызван ровно один раз."""
logger.info("Проверяем успешный рендер PDF (валидация HTML)")
fake_serialized = {
"full_name": "Jane Doe",
"role": "Dev",
"summary": "Summary",
"location": "Earth",
"languages": ["EN"],
"contacts": {"email": "jane@example.com", "phone": "", "telegram": ""},
"experience": [],
"skills_map": [],
}
def fake_serialize(_self: object, _p: object) -> dict:
return fake_serialized
captured: dict[str, Any] = {"html": "", "calls": 0}
class RecordingHTML:
def __init__(self, string: str) -> None:
captured["html"] = string
def write_pdf(self) -> bytes:
captured["calls"] += 1
return b"%PDF-FAKE"
monkeypatch.setattr("cv.services.dowload.pdf.ProfileSerializer.serialize", fake_serialize)
monkeypatch.setattr("cv.services.dowload.pdf.HTML", RecordingHTML)
out = PdfRenderer().render(cast(Profile, profile))
assert captured["calls"] == 1
assert isinstance(out.getvalue(), bytes)
html: str = captured["html"]
assert "<!DOCTYPE html>" in html
assert "<title>Jane Doe — Резюме (PDF)</title>" in html
assert "<h1>Jane Doe</h1>" in html
assert "<h2>Контакты</h2>" in html
assert "<h2>Опыт работы</h2>" in html
assert "<h2>Навыки</h2>" in html
assert "@page { size: A4;" in html
def test_render_missing_full_name_raises(monkeypatch: pytest.MonkeyPatch, profile: object) -> None:
"""Должен бросать ValueError при пустом full_name.
Args:
monkeypatch (pytest.MonkeyPatch): Инструмент подмены атрибутов.
profile (object): Фейковый профиль.
"""
logger.info("Проверяем ошибку при отсутствии full_name")
monkeypatch.setattr(
"cv.services.dowload.pdf.ProfileSerializer.serialize",
lambda _self, _p: {"full_name": ""},
)
with pytest.raises(ValueError):
PdfRenderer().render(cast(Profile, profile))

68
cv/tests/test_views.py Normal file
View File

@@ -0,0 +1,68 @@
"""Интеграционные тесты Django views приложения `cv`."""
import logging
from io import BytesIO
import pytest
from django.test import Client
from django.urls import reverse
from cv.models import Profile
from resume.utils.logging import configure_root_logger
logger = logging.getLogger(__name__)
configure_root_logger()
@pytest.mark.django_db
def test_profile_view_context(client: Client) -> None:
"""Контекст содержит profile при наличии записи.
Args:
client (Client): Django тестовый клиент.
"""
logger.info("Создаём профиль и проверяем контекст главной страницы")
Profile.objects.create(
full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[]
)
resp = client.get("/")
assert resp.status_code == 200
assert "profile" in resp.context
@pytest.mark.django_db
def test_download_pdf_ok(client: Client, monkeypatch: pytest.MonkeyPatch) -> None:
"""Выдача PDF c корректными заголовками.
Args:
client (Client): Django тестовый клиент.
monkeypatch (pytest.MonkeyPatch): Подмена рендера PDF.
"""
logger.info("Проверяем скачивание PDF с реальным роутом")
Profile.objects.create(
full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[]
)
monkeypatch.setattr("cv.views.PdfRenderer.render", lambda _self, _p: BytesIO(b"%PDF"))
resp = client.get(reverse("cv:resume-pdf"))
assert resp.status_code == 200
assert resp["Content-Type"] == "application/pdf"
assert resp["Cache-Control"] == "no-store"
assert "resume_John_Tester.pdf" in resp["Content-Disposition"]
@pytest.mark.django_db
def test_download_docx_ok(client: Client) -> None:
"""Smoke: скачивание DOCX с корректными заголовками."""
logger.info("Проверяем скачивание DOCX")
Profile.objects.create(
full_name="John Tester", role="QA", gender="male", summary="", location="", languages=[]
)
resp = client.get(reverse("cv:resume-docx"))
assert resp.status_code == 200
assert (
resp["Content-Type"]
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
assert resp["Cache-Control"] == "no-store"
assert "attachment; filename=" in resp["Content-Disposition"]
assert "resume_John_Tester.docx" in resp["Content-Disposition"]

View File

@@ -1,5 +1,8 @@
"""Маршруты приложения `cv`."""
from django.urls import path from django.urls import path
from .views import ProfileView, DownloadDocxView, DownloadPdfView
from .views import DownloadDocxView, DownloadPdfView, ProfileView
app_name = "cv" app_name = "cv"

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 django.views.generic.base import TemplateView, View
from cv.models import Profile from cv.models import Profile
from cv.services.dowload.docx import DocxRenderer from cv.services.dowload.docx import DocxRenderer
from cv.services.dowload.pdf import PdfRenderer from cv.services.dowload.pdf import PdfRenderer
class ProfileView(TemplateView): class ProfileView(TemplateView):
template_name = 'index.html' """Отображение профиля на главной странице."""
template_name = "index.html"
extra_context = {} 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) kwargs.setdefault("view", self)
extra_context = self._get_extra_context() extra_context = self._get_extra_context()
self.extra_context.update(extra_context) self.extra_context.update(extra_context)
@@ -16,19 +32,42 @@ class ProfileView(TemplateView):
kwargs.update(self.extra_context) kwargs.update(self.extra_context)
return kwargs return kwargs
def _get_extra_context(self): def _get_extra_context(self) -> dict[str, Any]:
profile = Profile.objects.prefetch_related('experience', 'skills_map').first() """Получить дополнительные данные для контекста (профиль и связанные сущности).
Returns:
dict: Дополнительные пары ключ/значение для контекста.
Raises:
Http404: Если профиль не найден.
"""
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile: if not profile:
raise Http404("Профиль не найден") raise Http404("Профиль не найден")
return { return {
'profile': profile, "profile": profile,
} }
class DownloadDocxView(View): class DownloadDocxView(View):
"""Скачать резюме в формате DOCX."""
_docx = DocxRenderer() _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() profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile: if not profile:
raise Http404("Профиль не найден") raise Http404("Профиль не найден")
@@ -48,9 +87,24 @@ class DownloadDocxView(View):
class DownloadPdfView(View): class DownloadPdfView(View):
"""Скачать резюме в формате PDF."""
_pdf = PdfRenderer() _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() profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile: if not profile:
raise Http404("Профиль не найден") raise Http404("Профиль не найден")

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main() -> None:
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'resume.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "resume.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

1248
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,15 +6,67 @@ authors = [
{name = " Pavel Sobolev",email = "p.sobolev@dineflow.app"} {name = " Pavel Sobolev",email = "p.sobolev@dineflow.app"}
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13,<4.0"
dependencies = [ dependencies = [
"django (>=5.2.8,<6.0.0)", "django (>=5.2.8,<6.0.0)",
"docxtpl (>=0.20.1,<0.21.0)", "docxtpl (>=0.20.1,<0.21.0)",
"weasyprint (>=66.0,<67.0)", "weasyprint (>=66.0,<67.0)",
"python-dotenv (>=1.0.1,<2.0.0)" "python-dotenv (>=1.0.1,<2.0.0)",
"rich (>=14.2.0,<15.0.0)"
] ]
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies]
ruff = "^0.14.4"
mypy = "^1.18.2"
django-stubs = "^5.2.7"
djangorestframework-stubs = "^3.16.5"
pandas-stubs = "^2.3.2.250926"
vulture = "^2.14"
pre-commit = "^4.4.0"
bandit = "^1.7.9"
pip-audit = "^2.7.3"
pytest = "^9.0.1"
pytest-django = "^4.11.1"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E","F","W","I","UP","D","B","SIM","C90"]
ignore = ["D203","D213"] # под Google-докстринги обычно отключают эти два
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.format]
preview = true
[tool.mypy]
python_version = "3.11"
plugins = ["mypy_django_plugin.main"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_return_any = true
exclude = ["migrations/"]
ignore_missing_imports = false
[tool.django-stubs]
django_settings_module = "resume.settings"
[tool.bandit]
skips = ["B101"] # при необходимости, можно убрать
exclude_dirs = [".venv", "venv", "build", "dist", ".tox", ".mypy_cache", ".ruff_cache", "node_modules"]
[tool.pip-audit]
require-hashes = false

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = resume.settings
python_files = tests.py test_*.py *_tests.py

View File

@@ -0,0 +1 @@
"""Пакет приложения `resume`."""

View File

@@ -1,48 +1,56 @@
from pathlib import Path """Настройки Django-проекта `resume`.
Содержит конфигурацию приложений, шаблонов, логирования, БД и статических файлов.
"""
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from .utils.env import env_bool, env_list from .utils.env import env_bool, env_list
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env") load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY",) SECRET_KEY = os.getenv(
"SECRET_KEY",
)
DEBUG = env_bool("DEBUG", False) DEBUG = env_bool("DEBUG", False)
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS") ALLOWED_HOSTS = env_list("ALLOWED_HOSTS")
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
"cv",
'cv',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
"libraries": { "libraries": {
"media": "resume.tags.media", "media": "resume.tags.media",
@@ -51,38 +59,73 @@ TEMPLATES = [
}, },
] ]
WSGI_APPLICATION = 'resume.wsgi.application' WSGI_APPLICATION = "resume.wsgi.application"
_DB_NAME_ENV = os.getenv("DATABASE_URL", "")
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": (BASE_DIR / os.getenv("DATABASE_URL")), "NAME": str(BASE_DIR / _DB_NAME_ENV),
} }
} }
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'Europe/Moscow' LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"rich": {
"format": "%(asctime)s [%(levelname)s] %(message)s",
"datefmt": "%H:%M:%S",
},
},
"handlers": {
"rich": {
"level": "INFO",
"class": "rich.logging.RichHandler",
"rich_tracebacks": True,
"show_time": False,
"formatter": "rich",
},
"console": { # запасной вариант без Rich (на случай отсутствия Rich)
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "rich",
},
},
"root": {
"level": "INFO",
"handlers": ["rich"],
},
"loggers": {
"django": {"level": "INFO", "handlers": ["rich"], "propagate": False},
"django.request": {"level": "WARNING", "handlers": ["rich"], "propagate": False},
},
}
LANGUAGE_CODE = "ru-ru"
TIME_ZONE = "Europe/Moscow"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
ROOT_URLCONF = 'resume.urls' ROOT_URLCONF = "resume.urls"
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
STATIC_URL = 'static/' STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = 'media/' MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"

View File

@@ -0,0 +1 @@
"""Пакет шаблонных тегов `resume.tags`."""

View File

@@ -1,37 +1,53 @@
"""Шаблонные теги для встраивания медиа как data URI."""
import base64 import base64
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
from typing import Optional, Union
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.core.files import File from django.core.files import File
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from django.utils.safestring import mark_safe
register = template.Library() register = template.Library()
def _resolve_path(value: Union[str, Path, FieldFile, File]) -> Optional[Path]:
if isinstance(value, (FieldFile, File)) and getattr(value, "name", None): def _resolve_path(value: str | Path | FieldFile | File) -> Path | None:
rel = value.name if isinstance(value, FieldFile | File) and getattr(value, "name", None):
elif isinstance(value, (str, Path)): rel: str = str(value.name)
elif isinstance(value, str | Path):
rel = str(value) rel = str(value)
else: else:
return None return None
p = Path(rel) # rel гарантированно строка к этому месту
p = Path(str(rel))
if p.is_absolute(): if p.is_absolute():
return p return p
return Path(settings.MEDIA_ROOT) / rel media_root: str | None = getattr(settings, "MEDIA_ROOT", None)
if not media_root:
return None
return Path(media_root) / rel
@register.simple_tag @register.simple_tag
def media(value: Union[str, Path, FieldFile, File]) -> str: def media(value: str | Path | FieldFile | File) -> str:
"""Вернуть data URI для файла по относительному пути или File/FieldFile.
Args:
value (str | Path | FieldFile | File): Относительный путь в MEDIA_ROOT
или объект файла Django.
Returns:
str: Строка вида "data:<mime>;base64,<...>" или пустая строка,
если файл не найден или не является обычным файлом.
"""
path = _resolve_path(value) path = _resolve_path(value)
if not path.exists() or not path.is_file(): if path is None or not path.exists() or not path.is_file():
return "" return ""
mime, _ = mimetypes.guess_type(str(path)) mime, _ = mimetypes.guess_type(str(path))
mime = mime or "application/octet-stream" mime = mime or "application/octet-stream"
data = path.read_bytes() data = path.read_bytes()
b64 = base64.b64encode(data).decode("ascii") b64 = base64.b64encode(data).decode("ascii")
uri = f"data:{mime};base64,{b64}" uri = f"data:{mime};base64,{b64}"
return mark_safe(uri) return uri

1
resume/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Тестовый пакет проекта `resume`."""

View File

@@ -0,0 +1,39 @@
"""Тесты для template-тега `media` проекта `resume`."""
import base64
import logging
from pathlib import Path
from django.test import SimpleTestCase, override_settings
from resume.tags.media import media
from resume.utils.logging import configure_root_logger
logger = logging.getLogger(__name__)
configure_root_logger()
@override_settings(MEDIA_ROOT="/tmp/resume_media_test")
class TestMediaTemplateTag(SimpleTestCase):
"""Тесты для template-тега media."""
def setUp(self) -> None:
"""Создать MEDIA_ROOT перед тестами."""
Path("/tmp/resume_media_test").mkdir(parents=True, exist_ok=True)
def test_media_returns_empty_for_missing(self) -> None:
"""Пустая строка, если файла нет."""
logger.info("Проверяем поведение для отсутствующего файла")
self.assertEqual(media("no_such_file.png"), "")
def test_media_returns_data_uri_for_existing_file(self) -> None:
"""Корректный data URI для реального файла."""
logger.info("Проверяем формирование data URI")
path = Path("/tmp/resume_media_test/test.txt")
content = b"hello"
path.write_bytes(content)
uri = media("test.txt")
self.assertTrue(uri.startswith("data:text/plain;base64,"))
b64 = uri.split(",", 1)[1]
self.assertEqual(base64.b64decode(b64), content)

View File

@@ -1,7 +1,6 @@
from django.contrib import admin """Маршрутизация URL для проекта `resume`."""
from django.urls import path, include
urlpatterns = [ from django.contrib import admin
path('admin/', admin.site.urls), from django.urls import include, path
path('', include('cv.urls'))
] urlpatterns = [path("admin/", admin.site.urls), path("", include("cv.urls"))]

View File

@@ -1,20 +1,52 @@
"""Утилиты чтения переменных окружения с безопасным логированием."""
from __future__ import annotations from __future__ import annotations
import logging
import os import os
from typing import List
logger = logging.getLogger(__name__)
def env_bool(name: str, default: bool = False) -> bool: def env_bool(name: str, default: bool = False) -> bool:
"""Получить булево значение из переменной окружения.
Args:
name (str): Имя переменной окружения.
default (bool): Значение по умолчанию, если переменная не установлена.
Returns:
bool: Значение переменной, интерпретированное как булево. Строка "true" (в любом регистре)
трактуется как True, остальные значения — как False.
"""
val = os.getenv(name) val = os.getenv(name)
if val is None: if val is None:
if logger.isEnabledFor(logging.INFO):
logger.info(f"ENV {name} отсутствует; используется default={default}")
return default return default
return val.lower() == "true" result = val.lower() == "true"
if logger.isEnabledFor(logging.INFO):
logger.info(f"ENV {name} интерпретирован как bool={result}")
return result
def env_list(name: str, default: List[str] | None = None) -> List[str]: def env_list(name: str, default: list[str] | None = None) -> list[str]:
"""Получить список строк из переменной окружения (разделитель — запятая).
Args:
name (str): Имя переменной окружения.
default (list[str] | None): Значение по умолчанию, если переменная пустая или отсутствует.
Returns:
list[str]: Список строк после split по запятой и trim пробелов. Пустые элементы исключаются.
"""
raw = os.getenv(name) raw = os.getenv(name)
if not raw: if not raw:
return default or [] result = default or []
return [item.strip() for item in raw.split(",") if item.strip()] if logger.isEnabledFor(logging.INFO):
logger.info(f"ENV {name} пуст/отсутствует; список (len={len(result)}) из default")
return result
parts = [item.strip() for item in raw.split(",") if item.strip()]
if logger.isEnabledFor(logging.INFO):
logger.info(f"ENV {name} разобран как список (len={len(parts)})")
return parts

35
resume/utils/logging.py Normal file
View File

@@ -0,0 +1,35 @@
"""Утилиты логирования с интеграцией RichHandler.
Предоставляет помощник для настройки корневого логгера на базе стандартного
модуля logging и `rich.logging.RichHandler` для улучшенной читаемости вывода
и наглядных трассировок исключений.
В модуле приняты:
- Формат: "%(asctime)s [%(levelname)s] %(message)s"
- Уровень: INFO по умолчанию
"""
import logging
from rich.logging import RichHandler
def configure_root_logger(
level: int = logging.INFO, datefmt: str | None = "%H:%M:%S"
) -> logging.Logger:
"""Настроить корневой логгер с RichHandler и вернуть корневой логгер.
Args:
level (int): Уровень логирования (например, logging.INFO).
datefmt (Optional[str]): Формат времени для меток времени.
Returns:
logging.Logger: Настроенный корневой логгер.
"""
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt=datefmt,
handlers=[RichHandler(rich_tracebacks=True, show_time=False)],
)
return logging.getLogger()

View File

@@ -1,5 +1,4 @@
""" """WSGI config for resume project.
WSGI config for resume project.
It exposes the WSGI callable as a module-level variable named ``application``. It exposes the WSGI callable as a module-level variable named ``application``.
@@ -11,6 +10,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'resume.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "resume.settings")
application = get_wsgi_application() application = get_wsgi_application()