First commite
This commit is contained in:
5
.env.sample
Normal file
5
.env.sample
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
*_pycache__
|
||||||
|
db.sqlite3
|
||||||
|
static
|
||||||
|
.venv
|
||||||
|
media
|
||||||
|
staticfiles
|
||||||
|
.env
|
||||||
30
README.md
Normal file
30
README.md
Normal 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
0
cv/__init__.py
Normal file
17
cv/admin.py
Normal file
17
cv/admin.py
Normal 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
6
cv/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CvConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'cv'
|
||||||
64
cv/migrations/0001_initial.py
Normal file
64
cv/migrations/0001_initial.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='Профиль'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
cv/migrations/__init__.py
Normal file
0
cv/migrations/__init__.py
Normal file
67
cv/models.py
Normal file
67
cv/models.py
Normal 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
|
||||||
30
cv/services/dowload/base.py
Normal file
30
cv/services/dowload/base.py
Normal 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
105
cv/services/dowload/docx.py
Normal 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
107
cv/services/dowload/pdf.py
Normal 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
125
cv/templates/index.html
Normal 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
3
cv/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
cv/urls.py
Normal file
10
cv/urls.py
Normal 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
68
cv/views.py
Normal 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
7
example.env
Normal 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
22
manage.py
Executable 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
1088
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
pyproject.toml
Normal file
20
pyproject.toml
Normal 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
0
resume/__init__.py
Normal file
88
resume/settings.py
Normal file
88
resume/settings.py
Normal 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
0
resume/tags/__init__.py
Normal file
37
resume/tags/media.py
Normal file
37
resume/tags/media.py
Normal 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
7
resume/urls.py
Normal 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
20
resume/utils/env.py
Normal 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
16
resume/wsgi.py
Normal 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()
|
||||||
Reference in New Issue
Block a user