add linters
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,4 +4,7 @@ static
|
|||||||
.venv
|
.venv
|
||||||
media
|
media
|
||||||
staticfiles
|
staticfiles
|
||||||
.env
|
.env
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
|||||||
40
.pre-commit-config.yaml
Normal file
40
.pre-commit-config.yaml
Normal file
@@ -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"]
|
||||||
20
README.md
20
README.md
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Пакет приложения `cv` (модели, админка, сервисы выгрузки резюме)."""
|
||||||
|
|||||||
38
cv/admin.py
38
cv/admin.py
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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="Профиль",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Пакет миграций приложения `cv`."""
|
||||||
|
|||||||
39
cv/models.py
39
cv/models.py
@@ -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="ФИО")
|
||||||
@@ -13,26 +18,32 @@ class Profile(models.Model):
|
|||||||
location = models.CharField(max_length=120, verbose_name="Местоположение")
|
location = models.CharField(max_length=120, verbose_name="Местоположение")
|
||||||
languages = models.JSONField(default=list, verbose_name="Языки")
|
languages = models.JSONField(default=list, verbose_name="Языки")
|
||||||
email = models.EmailField(null=True, blank=True, verbose_name="Email")
|
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")
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
"""Сгенерировать бинарный документ по профилю."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #111; }
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
h1 { margin: 0 0 6mm 0; font-size: 20pt; }
|
Roboto, Arial, sans-serif;
|
||||||
h2 { margin: 8mm 0 3mm 0; font-size: 13pt; border-bottom: 1px solid #ccc; padding-bottom: 2mm; }
|
color: #111;
|
||||||
p { margin: 0 0 3mm 0; line-height: 1.4; }
|
}
|
||||||
.meta { color: #555; }
|
h1 {
|
||||||
.tag { display: inline-block; border: 1px solid #ddd; padding: 2px 6px; border-radius: 10px; font-size: 9pt; color: #444; margin-right: 4px; }
|
margin: 0 0 6mm 0;
|
||||||
.item { margin: 0 0 5mm 0; }
|
font-size: 20pt;
|
||||||
.item-title { font-weight: 700; }
|
}
|
||||||
.item-period { color: #666; }
|
h2 {
|
||||||
ul { margin: 2mm 0 2mm 6mm; }
|
margin: 8mm 0 3mm 0;
|
||||||
</style>
|
font-size: 13pt;
|
||||||
</head><body>
|
border-bottom: 1px solid #ccc;
|
||||||
""")
|
padding-bottom: 2mm;
|
||||||
# Header
|
}
|
||||||
parts.append(f"<h1>{data['full_name']}</h1>")
|
p {
|
||||||
if data["role"]:
|
margin: 0 0 3mm 0;
|
||||||
parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
|
line-height: 1.4;
|
||||||
if data["summary"]:
|
}
|
||||||
parts.append(f"<p>{data['summary']}</p>")
|
.meta { color: #555; }
|
||||||
meta_tags = []
|
.tag {
|
||||||
if data["location"]:
|
display: inline-block;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #444;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.item { margin: 0 0 5mm 0; }
|
||||||
|
.item-title { font-weight: 700; }
|
||||||
|
.item-period { color: #666; }
|
||||||
|
ul { margin: 2mm 0 2mm 6mm; }
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
return css_rules
|
||||||
|
|
||||||
|
def _append_head(self, full_name: str) -> None:
|
||||||
|
"""Добавить секцию head с CSS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full_name (str): Полное имя для тега <title>.
|
||||||
|
"""
|
||||||
|
self.parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
|
||||||
|
self.parts.append(f"<title>{full_name} — Резюме (PDF)</title>")
|
||||||
|
self.parts.append("<style>")
|
||||||
|
self.parts.append(self._build_print_css())
|
||||||
|
self.parts.append("</style></head><body>")
|
||||||
|
|
||||||
|
def _append_header_section(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Добавить шапку с именем, ролью, summary и метками."""
|
||||||
|
self.parts.append(f"<h1>{data['full_name']}</h1>")
|
||||||
|
if data.get("role"):
|
||||||
|
self.parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
|
||||||
|
if data.get("summary"):
|
||||||
|
self.parts.append(f"<p>{data['summary']}</p>")
|
||||||
|
meta_tags: list[str] = []
|
||||||
|
if data.get("location"):
|
||||||
meta_tags.append(f"<span class='tag'>{data['location']}</span>")
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from django.test import TestCase
|
"""Тесты приложения `cv`."""
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -7,4 +10,4 @@ urlpatterns = [
|
|||||||
path("", ProfileView.as_view(), name="profile"),
|
path("", ProfileView.as_view(), name="profile"),
|
||||||
path("download/resume.docx", DownloadDocxView.as_view(), name="resume-docx"),
|
path("download/resume.docx", DownloadDocxView.as_view(), name="resume-docx"),
|
||||||
path("download/resume.pdf", DownloadPdfView.as_view(), name="resume-pdf"),
|
path("download/resume.pdf", DownloadPdfView.as_view(), name="resume-pdf"),
|
||||||
]
|
]
|
||||||
|
|||||||
72
cv/views.py
72
cv/views.py
@@ -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("Профиль не найден")
|
||||||
@@ -65,4 +119,4 @@ class DownloadPdfView(View):
|
|||||||
content_type="application/pdf",
|
content_type="application/pdf",
|
||||||
)
|
)
|
||||||
resp["Cache-Control"] = "no-store"
|
resp["Cache-Control"] = "no-store"
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
1179
poetry.lock
generated
1179
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,65 @@ 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"
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Пакет приложения `resume`."""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Пакет шаблонных тегов `resume.tags`."""
|
||||||
|
|||||||
@@ -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,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"))]
|
||||||
|
|||||||
@@ -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
35
resume/utils/logging.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user