First commite

This commit is contained in:
Pavel Sobolev
2025-11-12 23:49:00 +03:00
commit c4bb087aaf
28 changed files with 2090 additions and 0 deletions

5
.env.sample Normal file
View File

@@ -0,0 +1,5 @@
DEBUG=True
SECRET_KEY=change-this-to-long-random-string
ALLOWED_HOSTS=127.0.0.1,localhost
DATABASE_URL=db.sqlite3

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
*_pycache__
db.sqlite3
static
.venv
media
staticfiles
.env

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
## Resume (Django)
Коротко: персональный сайт-резюме на Django с выдачей DOCX/PDF «на лету».
### Мини‑настройка
1) Создайте `.env` (можно на основе `example.env`):
```
DEBUG=True
SECRET_KEY=change-this
ALLOWED_HOSTS=127.0.0.1,localhost
DATABASE_URL=sqlite:///db.sqlite3
```
2) Установите зависимости (poetry или pip):
```
poetry install
# или
pip install -r requirements.txt # если используете requirements
```
3) Миграции и запуск:
```
python manage.py migrate
python manage.py runserver
```
PDF рендерится через WeasyPrint. Для Linux/WSL установите системные библиотеки (cairo/pango/gdk-pixbuf, шрифты DejaVu), иначе PDF может не собираться.
### Данные
Профиль хранится в БД (модель `Profile` + связанные `Experience`, `SkillGroup`). Наполнение — через админку/скрипты/миграции по вашему выбору. Страница читает данные напрямую из БД.

0
cv/__init__.py Normal file
View File

17
cv/admin.py Normal file
View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import Profile, Experience, SkillGroup
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('role', 'full_name', 'gender')
list_display_links = ('full_name','role',)
@admin.register(Experience)
class ExperienceAdmin(admin.ModelAdmin):
list_display = ('profile', 'company', 'start_date', 'end_date')
list_display_links = ('profile', 'company',)
@admin.register(SkillGroup)
class SkillGroupAdmin(admin.ModelAdmin):
list_display = ('profile', 'group',)
list_display_links = ('profile', 'group',)

6
cv/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CvConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'cv'

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.8 on 2025-11-11 18:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=200)),
('role', models.CharField(max_length=120)),
('gender', models.CharField(choices=[('male', 'Мужчина'), ('female', 'Женщина')], max_length=10)),
('summary', models.TextField()),
('location', models.CharField(max_length=120)),
('languages', models.JSONField(default=list)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(max_length=20)),
('telegram', models.CharField(max_length=40)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'profile',
},
),
migrations.CreateModel(
name='Experience',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company', models.CharField(max_length=200)),
('start_date', models.DateField()),
('end_date', models.DateField(blank=True, null=True)),
('summary', models.TextField()),
('achievements', models.JSONField(default=list)),
('tech', models.JSONField(default=list)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='experience', to='cv.profile')),
],
options={
'db_table': 'experience',
'ordering': ['-start_date'],
},
),
migrations.CreateModel(
name='SkillGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group', models.CharField(max_length=100)),
('items', models.JSONField(default=list)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='skills_map', to='cv.profile')),
],
options={
'db_table': 'skill_group',
},
),
]

View File

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

View File

67
cv/models.py Normal file
View File

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

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from io import BytesIO
from typing import Any, Dict, Protocol
class ProfileSerializer:
"""Сериализатор данных профиля для рендереров."""
def serialize(self, profile) -> Dict[str, Any]:
return {
"full_name": profile.full_name,
"role": getattr(profile, "role", ""),
"summary": getattr(profile, "summary", ""),
"location": getattr(profile, "location", ""),
"languages": getattr(profile, "languages", []) or [],
"contacts": {
"email": getattr(profile, "email", ""),
"phone": getattr(profile, "phone", ""),
"telegram": getattr(profile, "telegram", ""),
},
"experience": list(profile.experience.all()),
"skills_map": list(profile.skills_map.all()),
}
class Renderer(Protocol):
def render(self, profile) -> BytesIO: ...

105
cv/services/dowload/docx.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from io import BytesIO
from docx import Document
from docx.shared import Pt, RGBColor
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from cv.services.dowload.pdf import ProfileSerializer
class DocxRenderer:
def __init__(self, ):
self.serializer = ProfileSerializer()
@staticmethod
def _add_divider(document: Document) -> None:
p = document.add_paragraph()
fmt = p.paragraph_format
fmt.space_before = Pt(6)
fmt.space_after = Pt(6)
p_elm = p._p
pPr = p_elm.get_or_add_pPr()
pBdr = OxmlElement("w:pBdr")
bottom = OxmlElement("w:bottom")
bottom.set(qn("w:val"), "single")
bottom.set(qn("w:sz"), "6")
bottom.set(qn("w:space"), "1")
bottom.set(qn("w:color"), "2a3347")
pBdr.append(bottom)
pPr.append(pBdr)
@staticmethod
def _month_year(dt) -> str:
ru_months = {
1: "январь", 2: "февраль", 3: "март", 4: "апрель",
5: "май", 6: "июнь", 7: "июль", 8: "август",
9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь",
}
return f"{ru_months.get(dt.month, dt.strftime('%B')).capitalize()} {dt.year}"
def render(self, profile) -> BytesIO:
data = self.serializer.serialize(profile)
buf = BytesIO()
doc = Document()
# Заголовок
doc.core_properties.title = data["full_name"]
if data["role"]:
doc.core_properties.subject = data["role"]
doc.add_heading(data["full_name"], level=0)
if data["role"]:
doc.add_paragraph(data["role"])
if data["summary"]:
doc.add_paragraph(data["summary"])
meta_parts: list[str] = []
if data["location"]:
meta_parts.append(data["location"])
if data["languages"]:
meta_parts.append("Языки: " + ", ".join(data["languages"]))
if meta_parts:
doc.add_paragraph("".join(meta_parts))
# Контакты
doc.add_heading("Контакты", level=1)
contacts = data["contacts"]
if contacts.get("email"):
doc.add_paragraph(f"Email: {contacts['email']}")
if contacts.get("phone"):
doc.add_paragraph(f"Телефон: {contacts['phone']}")
if contacts.get("telegram"):
doc.add_paragraph(f"Telegram: {contacts['telegram']}")
# Опыт
doc.add_heading("Опыт работы", level=1)
for i, e in enumerate(data["experience"]):
if i:
self._add_divider(doc)
period = f"{self._month_year(e.start_date)}{(self._month_year(e.end_date) if e.end_date else 'настоящее время')}"
p_company = doc.add_paragraph()
run_company = p_company.add_run(e.company)
run_company.bold = True
run_company.font.underline = True
run_company.font.color.rgb = RGBColor(0x2A, 0x33, 0x47)
p_period = doc.add_paragraph(period)
for r in p_period.runs:
r.font.color.rgb = RGBColor(0x99, 0xA2, 0xB2)
if e.summary:
doc.add_paragraph(e.summary)
for a in (e.achievements or []):
if a:
doc.add_paragraph(a, style="List Bullet")
if e.tech:
doc.add_paragraph("Технологии: " + ", ".join([t for t in e.tech if t]))
# Навыки
doc.add_heading("Навыки", level=1)
for g in data["skills_map"]:
if g.items:
doc.add_paragraph(f"{g.group}: " + ", ".join([i for i in g.items if i]))
doc.save(buf)
buf.seek(0)
return buf

107
cv/services/dowload/pdf.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from io import BytesIO
from weasyprint import HTML
from cv.services.dowload.base import ProfileSerializer
class PdfRenderer:
"""Формирует HTML на лету (без шаблона) и конвертирует в PDF."""
def __init__(self):
# template не используется, оставлен для совместимости интерфейса
self.serializer = ProfileSerializer()
def render(self, profile) -> BytesIO:
data = self.serializer.serialize(profile)
# Минимальный, печатный HTML с безопасными стилями
parts = []
parts.append("<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>")
parts.append(f"<title>{data['full_name']} — Резюме (PDF)</title>")
parts.append("""
<style>
@page { size: A4; margin: 18mm 16mm; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; color: #111; }
h1 { margin: 0 0 6mm 0; font-size: 20pt; }
h2 { margin: 8mm 0 3mm 0; font-size: 13pt; border-bottom: 1px solid #ccc; padding-bottom: 2mm; }
p { margin: 0 0 3mm 0; line-height: 1.4; }
.meta { color: #555; }
.tag { display: inline-block; border: 1px solid #ddd; padding: 2px 6px; border-radius: 10px; font-size: 9pt; color: #444; margin-right: 4px; }
.item { margin: 0 0 5mm 0; }
.item-title { font-weight: 700; }
.item-period { color: #666; }
ul { margin: 2mm 0 2mm 6mm; }
</style>
</head><body>
""")
# Header
parts.append(f"<h1>{data['full_name']}</h1>")
if data["role"]:
parts.append(f"<p class='meta'><strong>{data['role']}</strong></p>")
if data["summary"]:
parts.append(f"<p>{data['summary']}</p>")
meta_tags = []
if data["location"]:
meta_tags.append(f"<span class='tag'>{data['location']}</span>")
if data["languages"]:
meta_tags.append(f"<span class='tag'>Языки: {', '.join(data['languages'])}</span>")
if meta_tags:
parts.append(f"<p class='meta'>{' '.join(meta_tags)}</p>")
# Contacts
contacts = data["contacts"]
parts.append("<h2>Контакты</h2><p>")
if contacts.get("email"):
parts.append(f"<strong>Email:</strong> {contacts['email']}<br>")
if contacts.get("phone"):
parts.append(f"<strong>Телефон:</strong> {contacts['phone']}<br>")
if contacts.get("telegram"):
parts.append(f"<strong>Telegram:</strong> {contacts['telegram']}")
parts.append("</p>")
# Experience
parts.append("<h2>Опыт работы</h2>")
exp = data["experience"]
if exp:
for e in exp:
parts.append("<div class='item'>")
parts.append(f"<div class='item-title'>{e.company}</div>")
period = ""
if getattr(e, 'start_date', None):
period += e.start_date.strftime("%B %Y")
period += ""
period += e.end_date.strftime("%B %Y") if getattr(e, 'end_date', None) else "настоящее время"
parts.append(f"<div class='item-period'>{period}</div>")
if e.summary:
parts.append(f"<p>{e.summary}</p>")
if e.achievements:
parts.append("<ul>")
for a in e.achievements:
if a:
parts.append(f"<li>{a}</li>")
parts.append("</ul>")
if e.tech:
parts.append(f"<p class='meta'>Технологии: {', '.join(e.tech)}</p>")
parts.append("</div>")
else:
parts.append("<p class='meta'>Нет записей.</p>")
# Skills
parts.append("<h2>Навыки</h2>")
skills = data["skills_map"]
if skills:
for g in skills:
if g.items:
parts.append(f"<p><strong>{g.group}:</strong> {', '.join([i for i in g.items if i])}</p>")
else:
parts.append("<p class='meta'>Нет данных.</p>")
parts.append("</body></html>")
html = "".join(parts)
pdf = HTML(string=html).write_pdf()
out = BytesIO(pdf)
out.seek(0)
return out

125
cv/templates/index.html Normal file
View File

@@ -0,0 +1,125 @@
{% load static %}
{% load media %}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ profile.full_name }}</title>
<meta name="description" content="Персональный сайт-резюме — {{ profile.full_name }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'cv/css/style.css' %}">
</head>
<body class="container">
<header class="site">
<div class="header-left">
</div>
<div class="header-right">
<a class="btn outline" href="{% url 'cv:resume-pdf' %}" download>PDF</a>
<a class="btn outline" href="{% url 'cv:resume-docx' %}" download>DOCX</a>
</div>
</header>
<main>
<section class="hero">
<div class="hero__avatar" aria-hidden="true">
{% if profile.photo %}
<img src="{% media profile.photo %}" alt="{{ profile.full_name }}">
{% else %}
{{ profile.full_name|slice:":1" }}
{% endif %}
</div>
<div class="hero__content">
<h2>Привет, я {{ profile.full_name }}</h2>
{% if profile.role %}<p class="lead">{{ profile.role }} • {{ profile.location }}</p>{% endif %}
<p class="lead" style="margin-top:12px">{{ profile.summary }}</p>
</div>
</section>
<div class="grid">
<div>
<section id="experience" class="card">
<h3>Опыт работы</h3>
<div class="timeline" style="margin-top:10px">
{% for job in profile.experience.all %}
<article class="job">
<div class="company"><span style="opacity:.9">{{ job.company }}</span></div>
<div class="period">
{% with sd=job.start_date ed=job.end_date %}
{% if sd %}{{ sd|date:"F Y" }}{% endif %}{% if ed %} — {{ ed|date:"F Y" }}{% else %} — настоящее время
{% endif %}
{% endwith %}
</div>
{% if job.summary %}<div style="margin-top:8px;color:var(--muted)">{{ job.summary }}</div>{% endif %}
{% if job.achievements %}
<ul>
{% for a in job.achievements %}
<li>{{ a }}</li>
{% endfor %}
</ul>
{% endif %}
</article>
{% empty %}
<div class="job muted">Нет записей об опыте.</div>
{% endfor %}
</div>
</section>
</div>
<aside>
<section id="skills" class="card">
<h3>Навыки</h3>
<div style="margin-top:10px">
{% for group in profile.skills_map.all %}
<div style="margin-bottom:10px">
<div style="font-weight:700">{{ group.group }}</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px">
{% for s in group.items %}<span class="pill">{{ s }}</span>{% endfor %}
</div>
</div>
{% empty %}<div class="muted">Нет данных по навыкам.</div>{% endfor %}
</div>
</section>
<section id="contacts" class="card" style="margin-top:18px">
<h3>Контакты</h3>
<div style="margin-top:10px;display:flex;flex-direction:column;gap:10px">
{% if profile.email %}
<div>
<strong>Email:</strong>
<a href="mailto:{{ profile.email }}">{{ profile.email}}</a>
</div>
{% endif %}
{% if profile.phone %}
<div>
<strong>Телефон:</strong>
{{ profile.phone }}
</div>
{% endif %}
{% if profile.telegram %}
<div><strong>Telegram:</strong>
<a href="https://t.me/{{ profile.telegram|cut:'@' }}" target="_blank" rel="noreferrer">
{{profile.telegram}}
</a>
</div>
{% endif %}
{% if profile.git %}
<div>
<strong>Git:</strong>
<a href="{{ profile.git }}" target="_blank" rel="noreferrer">
{{ profile.git }}
</a>
</div>
{% endif %}
</div>
</section>
</aside>
</div>
</main>
</body>
</html>

3
cv/tests.py Normal file
View File

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

10
cv/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import ProfileView, DownloadDocxView, DownloadPdfView
app_name = "cv"
urlpatterns = [
path("", ProfileView.as_view(), name="profile"),
path("download/resume.docx", DownloadDocxView.as_view(), name="resume-docx"),
path("download/resume.pdf", DownloadPdfView.as_view(), name="resume-pdf"),
]

68
cv/views.py Normal file
View File

@@ -0,0 +1,68 @@
from django.http import Http404, FileResponse
from django.views.generic.base import TemplateView, View
from cv.models import Profile
from cv.services.dowload.docx import DocxRenderer
from cv.services.dowload.pdf import PdfRenderer
class ProfileView(TemplateView):
template_name = 'index.html'
extra_context = {}
def get_context_data(self, **kwargs):
kwargs.setdefault("view", self)
extra_context = self._get_extra_context()
self.extra_context.update(extra_context)
if self.extra_context is not None:
kwargs.update(self.extra_context)
return kwargs
def _get_extra_context(self):
profile = Profile.objects.prefetch_related('experience', 'skills_map').first()
if not profile:
raise Http404("Профиль не найден")
return {
'profile': profile,
}
class DownloadDocxView(View):
_docx = DocxRenderer()
def get(self, request, *args, **kwargs):
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile:
raise Http404("Профиль не найден")
buffer = self._docx.render(profile)
safe_utf8 = f"resume_{profile.full_name.replace(' ', '_')}.docx"
buffer.seek(0)
resp = FileResponse(
buffer,
as_attachment=True,
filename=safe_utf8,
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
resp["Cache-Control"] = "no-store"
return resp
class DownloadPdfView(View):
_pdf = PdfRenderer()
def get(self, request, *args, **kwargs):
profile = Profile.objects.prefetch_related("experience", "skills_map").first()
if not profile:
raise Http404("Профиль не найден")
stream = self._pdf.render(profile)
safe_utf8 = f"resume_{profile.full_name.replace(' ', '_')}.pdf"
resp = FileResponse(
stream,
as_attachment=True,
filename=safe_utf8,
content_type="application/pdf",
)
resp["Cache-Control"] = "no-store"
return resp

7
example.env Normal file
View File

@@ -0,0 +1,7 @@
# Базовые настройки Django (simple)
DEBUG=true
SECRET_KEY=change-me-in-prod
ALLOWED_HOSTS=127.0.0.1,localhost

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'resume.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

1088
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "resume"
version = "0.1.0"
description = ""
authors = [
{name = " Pavel Sobolev",email = "p.sobolev@dineflow.app"}
]
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"django (>=5.2.8,<6.0.0)",
"docxtpl (>=0.20.1,<0.21.0)",
"weasyprint (>=66.0,<67.0)",
"python-dotenv (>=1.0.1,<2.0.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

0
resume/__init__.py Normal file
View File

88
resume/settings.py Normal file
View File

@@ -0,0 +1,88 @@
from pathlib import Path
import os
from dotenv import load_dotenv
from .utils.env import env_bool, env_list
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.getenv("SECRET_KEY",)
DEBUG = env_bool("DEBUG", False)
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS")
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'cv',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
"libraries": {
"media": "resume.tags.media",
},
},
},
]
WSGI_APPLICATION = 'resume.wsgi.application'
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": (BASE_DIR / os.getenv("DATABASE_URL")),
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'Europe/Moscow'
USE_I18N = True
USE_TZ = True
ROOT_URLCONF = 'resume.urls'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / "media"

0
resume/tags/__init__.py Normal file
View File

37
resume/tags/media.py Normal file
View File

@@ -0,0 +1,37 @@
import base64
import mimetypes
from pathlib import Path
from typing import Optional, Union
from django import template
from django.conf import settings
from django.core.files import File
from django.db.models.fields.files import FieldFile
from django.utils.safestring import mark_safe
register = template.Library()
def _resolve_path(value: Union[str, Path, FieldFile, File]) -> Optional[Path]:
if isinstance(value, (FieldFile, File)) and getattr(value, "name", None):
rel = value.name
elif isinstance(value, (str, Path)):
rel = str(value)
else:
return None
p = Path(rel)
if p.is_absolute():
return p
return Path(settings.MEDIA_ROOT) / rel
@register.simple_tag
def media(value: Union[str, Path, FieldFile, File]) -> str:
path = _resolve_path(value)
if not path.exists() or not path.is_file():
return ""
mime, _ = mimetypes.guess_type(str(path))
mime = mime or "application/octet-stream"
data = path.read_bytes()
b64 = base64.b64encode(data).decode("ascii")
uri = f"data:{mime};base64,{b64}"
return mark_safe(uri)

7
resume/urls.py Normal file
View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('cv.urls'))
]

20
resume/utils/env.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import os
from typing import List
def env_bool(name: str, default: bool = False) -> bool:
val = os.getenv(name)
if val is None:
return default
return val.lower() == "true"
def env_list(name: str, default: List[str] | None = None) -> List[str]:
raw = os.getenv(name)
if not raw:
return default or []
return [item.strip() for item in raw.split(",") if item.strip()]

16
resume/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for resume project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'resume.settings')
application = get_wsgi_application()