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

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