Документация
ARCHITECTURE.md — ТЗ проекта
Как должно быть. Бизнес-логика, сценарии, модели, API, инфраструктура. Статус —
STATE.md. История —CHANGES.md. Чек-лист —PRODUCTION.md. Оборудование для Олега —SETUP_HOME.md. Команды —COMMANDS.md.
Формат сессий: только даты в CHANGES.md (2026-10-14, 2026-10-14-б).
В остальных доках — без номеров сессий.
0. Стандарт оформления страниц
Дефолтный каркас любой страницы:
- Фон — градиент + 3 софита + боке + виньетка.
- Главная (
home.html): софиты двигаются и меняют цвет, боке «дышат». - Остальные страницы: софиты при загрузке получают случайный цвет и позицию черезrandomizeSpotlights(), дальше статичны.
Реализация: static/css/common.css + static/js/common.js.
Отдельный модуль лайтбокса — static/js/lightbox.js + static/css/lightbox.css.
Верхний слой .page-top — единый fixed-слой во всю ширину .content.
Слева «Назад» + «Вверх», справа — header_actions.html.
Позиция: top: 20px, left/right: max(48px, calc((100vw - 1100px)/2 + 48px)).
pointer-events: none на контейнер, auto на детей.
Универсальный блок header_actions.html — избранное (закладка) ·
имя/войти · корзина. Счётчики через FavoritesModule / CartModule.
У анонима корзина opacity: 0. Клик по имени — ZnymkariAuthModal.open()
или редирект на /?auth=login|signup|profile. Подключён на всех страницах,
кроме главной.
App-shell: body.app-shell + .app-scroll — body ровно по окну,
скроллится только .app-scroll. Применён на prices, dev_links, cameras,
cart, favorites, events_list, docs. Не применён: home (временный фикс),
event (свой inline), markup.
- Шапка — заголовок по центру. Слева — «Назад» + «Вверх».
- Футер — единый,
{% include "catalog/includes/footer.html" %}. - Карта сайта — pull-up панель,
{% include "catalog/includes/sitemap.html" %}. <body data-event-id="...">— обязательный атрибут.- Цвета: розовый
#ff5ebc, тёмный фон#0a0a12, серые#888/#555.
Кнопка «Назад» — setupBackLink в common.js. Есть история → history.back().
Иначе — на /.
1. Суть бизнеса
Видеограф/фотограф снимает конкурсы, фестивали, концерты. Сайт-магазин показывает контент «товаром лицом», продаёт поштучно и оптом, автоматизирует выдачу после оплаты.
Ключевые решения:
- Массовая разметка медиа — выделить группу файлов → навесить метку.
- Видео — постер + 10-сек превью; полное видео после оплаты.
- Оптовые продажи со скидкой за пакет.
- Воронка через лайки.
- Выдача после оплаты — через одноразовую ссылку в Telegram-боте. Клиент качает файл напрямую с домашнего ноутбука Олега.
- Клиентский таймлайн — три зоны (Блок, Категория, Выступление) с независимой шкалой времени.
- Хлебные крошки показывают путь.
- Галерея превью — основа страницы ивента, с лайтбоксом, зумом, лайками.
- Избранное → Корзина → Заказ — воронка. Избранное — «карантин», корзина — «почти покупка».
- Импорт медиа полностью локальный — происходит на ноуте Олега, VPS-БД не трогается до атомарного merge.
1.1. Два проекта
| Проект | Где живёт | Роль |
|---|---|---|
web/ |
VPS znymkari.com |
Сайт-витрина: клиент, корзина, оплата, выдача |
znymkari-data/ |
Ноут oleg-home |
Цех: импорт, sanitize, превью, push |
Связь:
- Один endpoint на VPS — POST /internal/receive-event/.
- Одна management-команда на ноуте — push_event.
- Одинаковая схема БД на обоих → pg_dump / pg_restore без конверсий.
Что общего в коде:
- Модели: Event, Block, Category, Performance, Media,
Organizer, TeamMember, ProgramScan, Expense, ExtraIncome.
- Логика импорта (import_service.py), генерация постеров видео
(generate_video_posters).
Что НЕ переносится в znymkari-data:
- accounts, telegram, issues, orders.
- Favorite, CartItem, UserEventDiscount.
- Клиентские views, шаблоны сайта, статика сайта.
- Telegram webhook, регистрация, оплата.
1.2. Роли пользователей
- Гость — ищет «своих» через поиск и таймлайны. Отбирает в Избранное
(
localStorage). - Залогиненный клиент — переносит медиа между Избранным и Корзиной, покупает.
- Олег — контент-менеджер. Снимает, импортирует, размечает.
Работает на ноуте через мини-админку
znymkari-data. - Разработчик — поддерживает оба проекта. SSH на VPS и ноут.
1.3. Типы клиентов
- Мама — 1–2 выступления своего ребёнка. Фото + награждение + бэкстейдж.
- Руководитель коллектива — 5–12 выступлений. Собирает заказы, раздаёт. Возможен кэшбек.
2. Пользовательские сценарии
2.0. Воронка клиента
- Гость заходит. Ищет «своих» через поиск и таймлайны.
Отбирает в Избранное (
localStorage). - Жмёт «Как купить?» (модалка-инструкция).
- Регистрация (Telegram — основной, email — запасной).
- На входе — merge-промпт, если есть выбор гостя.
- Залогиненный переносит медиа между Избранным и Корзиной.
- В корзине — Умная корзина (допродажи, пакеты, скидки).
- «Купить» → оплата.
- После оплаты — бот шлёт одноразовую ссылку
https://files.znymkari.com/d/<token>. - Клиент качает файлы напрямую с ноута Олега.
- Файлы в БД:
is_purchased=True+ FK на покупателя.
2.1. Главная — карусель ивентов
Шапка (два слоя): верхний — гамбургер + избранное + имя + корзина; нижний — даты + год со стрелками.
Авторизация: аноним → «Войти», залогинен → розовая кнопка с именем → профиль.
Избранное в шапке: закладка + счётчик. Аноним — серая, залогинен — розовая.
Корзина: иконка сверху, число снизу. Аноним — закладка, серый. Залогинен — 🛒, зелёный.
Карусель: 5 шаров. Центральный — крупный, ротация каждые 3 сек
(Ken Burns + fade). Боковые — уменьшенные. На узком экране (<800px) боковые скрыты.
URL автосохраняет ?year=2026&event=5.
Поиск — модалка с полем ввода, поиск по названию и городу.
Кнопка «Как купить?» — см. §2.7.
2.2. Клиентская страница ивента /event/<id>/
App-shell: body — height: 100vh; overflow: hidden. Скроллится только
.gallery-grid.
Шапка: «‹ Назад», название ивента, время, дата, город.
Хлебные крошки: три слота (Все блоки / Все категории / Все выступления). Цвета: блок — синий, категория — красный, выступление — жёлтый, награждение — зелёный.
По бокам от крошек — стрелки ‹ › (.bc-nav). Листают соседние выступления
внутри категории. Активны только когда currentPerfItem задан и есть сосед.
Переход без перезагрузки. Клик — stopPropagation (иначе откроется модалка таймлайнов).
Панель кнопок: Выделено: N слева. Справа:
[🏃→🔖] · Сетка · Всё · Инвертировать · [N→🔖] · Просмотр.
[🏃→🔖]— «Быстро всё выступление в избранное». Модалка подтверждения.[N→🔖]— перенос выделенного без модалки.
Режим «Просмотр» — включён по умолчанию. Синий фон галереи, клик по тайлу → лайтбокс.
Галерея: сетка repeat(auto-fill, minmax(var(--tile-size), 1fr)),
50 медиа на страницу. Сортировка по exact_datetime. Видео — тайл с постером +
иконка ▶. На каждом тайле — сердечко с счётчиком. likes_count >= 100 → ∞ + золото.
Лайтбокс (пока свой, в плане — общий модуль):
- Превью на весь экран. Зум ×2 ступенями до 1:1, drag.
- Стрелки ‹ ›, клавиши ← →, Esc, клик по фону.
- Под фото: подпись + две взаимоисключающие кнопки: «В избранное» / «Отмена».
- Сердечко-счётчик кликабельно.
- Видео: <video autoplay loop playsinline controls>. muted = true,
по playing — снять muted.
- Кнопка «Проверить качество» (план) — подгружает 60% оригинала с QR.
Фильтр галереи. Передаёт ?level=&id=&block=&category= — все три параметра.
Модалка таймлайнов: - Три горизонтальные полосы (Блок / Категория / Выступление). - Клик по уровню → фильтр нижних + подсветка родительских. - Повторный клик снимает выделение. - При закрытии — текущий выбор становится фильтром галереи.
2.2-bis. Корзина /catalog/cart/
Только для залогиненных. Анонима редиректит на /catalog/favorites/.
Хранилище: серверное (CartItem). Синхронизируется между устройствами.
Структура: Ивент → Блок·Категория·Выступление → Плитка превью.
Особенности:
- App-shell. Шапка с header_actions, заголовок «Корзина».
- Фото и видео — двумя рядами, пунктирный разделитель между ними.
- Шеврон раскрытия в шапке группы справа.
- Блок «всего N» кликабельный.
- Оффер-строки со стрелкой →.
- Итог по ивенту: N фото · M видео, справа сумма.
- Средняя цена — «за медиа».
- Размер S/M/L — 130/190/270.
- Компактный вид строки при нехватке места.
- Выделение — оранжевое, 4px.
- Лайтбокс — общий модуль. Две кнопки: «Вернуть в избранное» / «Убрать совсем».
- Кнопка «Купить» → Этап 3.
2.2-ter. Общий лайтбокс Lightbox
Файлы: static/js/lightbox.js + static/css/lightbox.css.
DOM создаётся один раз.
API:
Lightbox.open({
items: [{ id, preview, likes, type }, ...],
index: 0,
showLike: true,
note: "* Вы покупаете оригинал...",
buttons: [ // массив ИЛИ функция (item) => [...]
{
icon: "fa-bookmark",
label: "Убрать из Избранного",
css: "lb-btn-danger", // lb-btn-danger | lb-btn-success | ""
onClick: (id, item) => { ... }
},
],
onLike: (id, newLikes) => {},
onClose: () => {},
});
Lightbox.close();
Lightbox.isOpen();
Возможности: placeholder 600px → large, зум ×2 ступенями, drag, мета-строка, стрелки, Esc, клик по фону, лайк.
Кнопки по страницам:
| Страница | Кнопка 1 | Кнопка 2 |
|---|---|---|
event.html (план) |
«В Избранное» ↔ «Убрать» | — |
favorites.html |
🔖 «Убрать» (красная) | 🛒 «В корзину» (зелёная) |
cart.html |
🔖 «Вернуть в избранное» (зелёная) | 🗑 «Убрать совсем» (красная) |
Правило: если действие убирает фото из контекста — лайтбокс закрывается.
Лайки — сразу. Один клик = один POST.
2.2-quater. Авторизация, регистрация, профиль
Два способа:
- Telegram (основной). Один клик. Бот
@Znymkari_dev_bot(id8807489402, dev). - Email + пароль + активация (запасной). Под «Или по email».
Модель — стандартная django.contrib.auth.User:
- username = email (email-флоу) или tg_<telegram_id> (Telegram).
- Вход — по email ИЛИ username.
- Telegram-юзеры: set_unusable_password().
- Email-регистрация → is_active=False до активации.
- Telegram-регистрация → is_active=True сразу.
URLs:
- /accounts/signup/?next=<url> (POST)
- /accounts/login/?next=<url> (POST)
- /accounts/logout/ (POST)
- /accounts/activate/<uidb64>/<token>/?next=<url> (GET, HTML)
- /accounts/delete/ (POST) — мягкое удаление
- /accounts/profile/ — личный кабинет
- /accounts/update-profile/ (POST, JSON)
Telegram-флоу:
- POST /accounts/telegram/init/ → {token, url}.
- GET /accounts/telegram/status/?token= → pending | done | expired.
- POST /accounts/telegram/finish/ — логинит в браузерной сессии.
- POST /accounts/telegram/confirm/ — вызов из бота, защищён X-Telegram-Secret.
Что получаем от Telegram: telegram_id, first_name, last_name,
username, language_code. Email и телефон — нет. Телефон — через
request_contact.
Модалка авторизации — только вход / регистрация. Профиль и опасная зона —
на /accounts/profile/.
Merge-промпт при входе/регистрации:
- «До входа на сайт, вы положили N медиа в Избранное. Добавить их или отменить?»
- Три кнопки: «Оставить как есть» / «Отмена» / «Добавить».
- Флаг Znymkari:merge-prompt-done:<user_id> в sessionStorage.
Личный кабинет /accounts/profile/:
- Показывает: аватар, имя, статус, логин, email, Telegram, телефон, дату,
«В избранном: N», «Заказов: N».
- Кнопки: Редактировать / Избранное / Корзина / Выйти.
- Опасная зона — удаление аккаунта (soft-delete: is_active=False).
2.3. Прайс /catalog/prices/
Жёлтый лист. Параметр ?event=<id> — цены этого ивента. Без параметра —
свежего по start_date.
2.3-bis. Умная корзина
Endpoint: POST /catalog/api/client/cart/smart/ (JSON {ids}). Stateless.
Правила:
| № | Тип | Условие | Что делает |
|---|---|---|---|
| 1 | Оффер | Не награда, not full_photos |
photo_package 🔴 «Возьмите остальные N фото» |
| 1b | Оффер | Не награда, full_photos |
photo_package ✅ |
| 2 | Авто | Все фото взяты | photos_base = package |
| 3 | Авто | full_photos + videos_n > 0 |
Скидка discount_combo (один раз на группу) |
| 3b | Оффер | full_photos + videos_n == 0 |
video_combo 🔴 |
| 4 | Оффер | full_photos + award_on_main_stage=True + нет награды |
award_free 🔴 |
| 4b | Авто | Награда в группе + full_photos + award_on_main_stage=True |
Награда → 0 ₴ |
| 5 | Info | Всегда | Удельная цена |
| 6 | Авто | Активная UserEventDiscount |
final × (1 − percent/100) |
Три типа офферов — с флагом applied: true/false.
Награда — отдельная подгруппа внутри группы выступления. Порядок: фото → награда → видео.
Привязка награды — POST /catalog/api/client/media/attach-performance/.
2.4. Список ивентов /catalog/events/
Таблица с фиксированной шапкой. Колонки: Название · Организатор · Город ·
Начало · Окончание · Фото · Видео. Группировка по годам (аккордеон).
Фильтры по организатору/городу. Сортировка. Клик по строке → /event/<id>/.
Экспорт (JSON/PDF/Excel) — не реализован.
2.5. Единый футер
{% include "catalog/includes/footer.html" %}. Одна строка. По центру: телефон,
логотип, соцсети. Слева внизу — версия v<проект>.<билд> · env · date.
Клик по пустому месту → карта сайта.
Карта сайта — три колонки: Клиенту / Ивенты / Администратору (или Связь).
Ссылки «Текущий ивент», «Разметка ивента», «Часы камер» — динамические
из body.dataset.eventId.
2.6. Поиск выступления
Толерантный поиск (подстрока, синонимы). Не реализовано.
2.7. Кнопка «Как купить?»
Модалка-инструкция для гостя: 5 шагов, две кнопки («Зарегистрироваться», «Войти»),
счётчик избранного. Реализована в includes/buy_modal.html.
2.8. Знимкарі Лабораторія (проект znymkari-data)
Шесть страниц. Django templates + минимум JS (polling для прогресса).
Доступ — только по LAN http://192.168.31.87:8000/.
/ — домашняя:
- Приветствие, кнопка «Проверить скорость» (Speedtest).
- Крупные кнопки: Импорт · Отправка · Хранилище · Чистка · Настройки.
- Сводка: сколько ивентов, медиафайлов, места на дисках.
- Таблица последних импортов.
/import/ — импорт:
- Таблица сопоставляет папки в zyn-festivals с ивентами в БД.
- Колонки: Папка, Ивент, Город, Дата, Статус, Действие.
- Если ивента нет в базе — кнопка «➕ Добавить», открывающая модалку создания (вводится название, город, дата). source_folder привязывается автоматически.
- Если ивент есть — кнопка «Открыть», ведущая на прогресс импорта.
- Прогресс: ETA, 3 активных потока, объем (ГБ), два прогресс-бара, лог с автоскроллом.
/send/ — отправка на VPS:
- Таблица ивентов с колонками: Импортирован, Спакован, Отправлен.
- Кнопки «Запаковать» (сборка архива) и «Отправить на сервер» (SFTP + receive_event).
- Прогресс-бар, логи, кнопка «Копировать лог».
/cleanup/ — чистка:
- Поиск папок-сирот в STORAGE/PREVIEW.
- Поиск пустых ивентов (0 медиа).
- Поиск зависших задач ImportJob (running > 12 часов).
- Поиск старых архивов (отправленных, но не удаленных из dist/).
/storage/ — инвентарь хранилища:
- Таблица: ивент, размер, дата импорта, статус push.
- Удалить ивент из рабочей копии (с подтверждением).
/settings/ — настройки:
- Пути: STORAGE, FESTIVALS, PREVIEW.
- VPS: host, user. Кнопка «Проверить связь с VPS».
- Статус Telegram-бота.
Модель ImportJob (для /import/<id>/):
| Поле | Описание |
|---|---|
event |
FK на Event |
source_folder |
путь на машине импорта |
status |
pending / running / done / failed / cancelled / stalled |
created_by |
FK User |
config |
JSON — параметры (q, widths, ext) |
progress |
JSON — {current, total, phase} |
stats |
JSON — {added, skipped, errors} |
log |
Text — последние N строк |
created_at, started_at, finished_at |
3. Модель данных
3.1. Иерархия
Event
├── Block
│ └── Category (FK на Block, но может быть без него)
│ └── Performance
│ └── Media
Ссылки снизу вверх. У Media всегда есть event, необязательно
performance / category / block. Разметка — по медиа, а не по выступлениям.
Что реально в коде: Category.block — FK на Block (не через медиа, как было
в старой доке). null=True, on_delete=SET_NULL.
3.2. Справочники
Organizer — name, contact_person, phone, email, address,
telegram, instagram, facebook, terms.
TeamMember — full_name, role, phone.
ProgramScan — event, scan_path.
Expense — event, description, amount.
ExtraIncome — event, when, method, amount.
3.3. Event
| Поле | Тип | Обяз. | Дефолт |
|---|---|---|---|
title |
Char(255) | да | — |
city |
Char(120) | да | — |
start_date |
Date | да | — |
end_date |
Date | нет | null |
organizer |
FK Organizer, PROTECT | нет | null |
venue |
Char(255) | нет | "" |
source_folder |
Char(500) | нет | "" |
comment |
Text | нет | "" |
staff_on_site |
M2M TeamMember | нет | — |
old_data_deleted |
Bool | да | False |
award_on_main_stage |
Bool | да | False |
Прайс (12 полей):
| Поле | Дефолт |
|---|---|
price_photo_solo |
660 |
price_photo_duo |
770 |
price_photo_group |
880 |
price_photo_single |
110 |
price_video_solo |
660 |
price_video_duo |
770 |
price_video_group |
880 |
price_edited_video_solo |
1320 |
price_edited_video_duo |
1540 |
price_edited_video_group |
1760 |
discount_combo |
100 |
discount_cash_percent |
10 |
Поля синхронизации (в коде с 2026-10-15-б):
| Поле | Зачем |
|---|---|
external_id |
UUID, идемпотентный merge при push_event |
slug |
{id:04d}_{sanitized_title} — имя папки на диске |
3.4. Block / Category
| Block | Category |
|---|---|
event |
event |
title |
block (FK, null=True, SET_NULL) |
comment |
title |
comment |
Названия — как называют организаторы, без нормализации. comment — свободный
текст, ключевые слова («бачата, пасадобль, латина», «бэкстейдж у гимнастов»).
3.5. Performance
event, block, category, kind («Номер», «Танец», «Песня»),
size_category (solo / duo / group), participant_number,
participant_name, director, description.
3.6. Media
Реальные поля:
| Поле | Тип | Дефолт | Описание |
|---|---|---|---|
event |
FK Event, CASCADE | — | |
block / category / performance |
FK, SET_NULL | null | |
author |
FK TeamMember, SET_NULL | null | Эвристика по роли |
media_type |
Char(20) | — | photo / video / edited_video |
filename |
Char(255) | — | Оригинальное имя файла |
exact_datetime |
DateTime | — | Время съёмки |
end_datetime |
DateTime | null | Для видео |
likes_count |
PositiveInteger | 0 | Порог ∞ = 100 |
is_purchased |
Bool | False | Ставится при оплате |
is_protected |
Bool | False | Заморозка от чистки |
storage_path |
Char(500) | "" | Относительный от STORAGE_ROOT |
preview_path |
Char(500) | "" | Относительный от PREVIEW_ROOT |
video_poster_path |
Char(500) | "" | |
video_preview_path |
Char(500) | "" | 10-сек mp4 |
relative_path |
Char(500) | "" | Путь к папке от корня ивента |
expires_at |
DateTime | null | Удалить после |
camera_key |
Char(255) | "" | make|model|serial|lens из EXIF |
camera_offset_seconds |
Integer | 0 | Сдвиг часов камеры |
Путь файла — собирается на лету:
Path(STORAGE_ROOT) / media.storage_path
Path(PREVIEW_ROOT) / media.preview_path
Абсолютных путей в БД нет — только относительные. Это позволяет
переключать STORAGE_ROOT (dev / прод) без правок БД.
Поля — план:
| Поле | Зачем |
|---|---|
width / height |
Размеры оригинала. Нужны для 60%-превью в лайтбоксе |
3.7. Favorite / CartItem / UserEventDiscount
Favorite — user, media, added_at. unique_together = (user, media).
CartItem — user, media, added_at. unique_together = (user, media).
UserEventDiscount — per-user, per-event:
- user, event, percent (0..100), reason, is_active,
valid_until, created_at, created_by.
- unique_together = (user, event).
- Метод is_effective(): активна И (бессрочна ИЛИ срок не истёк).
3.8. UserProfile / TelegramLinkToken
UserProfile (OneToOne к User):
| Поле | Тип |
|---|---|
telegram_id |
BigInt, unique, null |
telegram_username |
Char(64), null |
telegram_first_name |
Char(128), null |
telegram_last_name |
Char(128), null |
telegram_language |
Char(8), null |
preferred_language |
Char(8), null |
phone |
Char(32), null |
source |
email / telegram |
bot_blocked |
Bool |
notifications_enabled |
Bool |
telegram_avatar_file_id |
Char |
created_at, updated_at |
Создаётся сигналом post_save(User).
TelegramLinkToken — token, user, created_at, used_at.
3.9. Issue
title, description, status (new / in_progress / fixed /
wontfix / duplicate), priority (low / normal / high / critical),
category (bug / design / feature / content / other),
page_type, page_url, reporter, assignee, resolved_at, resolved_by,
resolution_note, created_at.
3.10. Модели — план
Order (Этап 3):
| Поле | Описание |
|---|---|
user |
FK, PROTECT |
total |
Итоговая сумма |
status |
new / pending / paid / cancelled / refunded |
payment_method |
LiqPay / Fondy / … |
referrer_user |
FK, null — для кэшбека |
created_at |
auto_now_add |
paid_at |
null |
OrderItem: order, media (PROTECT), price (снапшот),
media_type (снапшот).
DownloadToken:
| Поле | Описание |
|---|---|
token |
UUID, unique, db_index |
order |
FK Order, CASCADE |
created_at |
auto_now_add |
expires_at |
DateTime — now + 72h |
max_downloads |
Int — по умолчанию = число файлов в заказе |
downloads |
Int, default 0 |
last_used_at |
DateTime, null |
ip_last |
Char(45), null |
Метод is_valid(): не истёк, downloads < max_downloads.
ImportJob — см. §2.8.
3.11. Синхронизация ноут → VPS
Транспорт — через JSON и ORM, не через pg_dump. Причины:
- pk на ноуте и VPS не совпадают. pg_dump тянет их за собой, а с ними —
все FK-ссылки.
- Organizer и TeamMember не привязаны к одному ивенту — pg_dump --where
их не вытащит штатно.
- ON CONFLICT работает только по одному ключу, а у нас их несколько.
Вместо этого pack_event сериализует объекты через
django.core.serializers в data.json, а receive_event читает JSON и
сливает через ORM, пересчитывая pk на лету.
Что НЕ шлём с ноута на VPS (хозяин — VPS):
- Favorite, CartItem, UserEventDiscount.
- UserProfile, TelegramLinkToken.
- Issue.
- Order, OrderItem, DownloadToken.
- Organizer и TeamMember — справочники живут только на VPS.
На ноуте они не заполняются и не показываются в админке.
- Expense, ExtraIncome, ProgramScan — учёт и файлы на VPS.
Что шлём:
Event, Block, Category, Performance, Media.
Правила merge (мягкий merge, хозяин поля):
При создании объекта ставим всё из JSON. При обновлении существующего — только белый список полей.
| Объект | Обновляем с ноута | НЕ трогаем (хозяин VPS) |
|---|---|---|
Event |
title, city, start_date, end_date, venue, source_folder, comment | прайс (price_), discount_, old_data_deleted, award_on_main_stage, organizer, staff_on_site, external_id, slug |
Block |
title, comment | — |
Category |
title, comment, block | — |
Performance |
kind, size_category, participant_number, participant_name, director, description, block, category | — |
Media |
filename, exact_datetime, end_datetime, media_type, camera_key, camera_offset_seconds, preview_path, video_poster_path, video_preview_path, relative_path | likes_count, is_purchased, is_protected, expires_at, block, category, performance, author |
Удаления при merge не делаются никогда. Если на ноуте медиа исчезло — на VPS оно останется.
Схема пакета event_<slug>.tar.gz:
event_0001_vesnyana_feyeriya.tar.gz
├── data.json ← Django fixtures: Event, Block, Category,
│ Performance, Media этого ивента
├── meta.json ← {slug, event_id, external_id, media_count, ...}
└── previews/
└── <slug>/
├── Kamera_1/
│ ├── foto_preview.jpg
│ ├── video_poster.jpg
│ └── video_preview.mp4
└── ...
Формат meta.json:
{
"slug": "0001_vesnyana_feyeriya",
"event_id": 1,
"external_id": "550e8400-e29b-41d4-a716-446655440000",
"media_count": 1523,
"preview_files": 1523,
"data_size": 12345678,
"total_size": 12345678,
"created_at": "2026-10-16T18:00:00+03:00"
}
Команда pack_event <event_id> (на ноуте):
1. Берёт Event и всю его иерархию (Block, Category, Performance, Media).
Organizer и TeamMember намеренно не берёт.
2. Сериализует в data.json через django.core.serializers.
3. Пишет meta.json.
4. Копирует PREVIEW_ROOT/<slug>/ в previews/<slug>/ внутри архива.
5. tar -czf event_<slug>.tar.gz → dist/.
Команда push_event <event_id> (на ноуте):
1. Находит dist/event_<slug>.tar.gz.
2. paramiko SFTP → /tmp/znymkari-incoming/ на VPS.
3. По SSH запускает receive_event <slug> на VPS, стримит вывод.
Команда receive_event <slug> (на VPS):
1. Берёт /tmp/znymkari-incoming/event_<slug>.tar.gz.
2. Распаковывает во временную папку (защита от path traversal).
3. Читает meta.json и data.json.
4. transaction.atomic(), в порядке:
- Event — ищем по external_id. Нет — создаём (со всеми полями,
включая прайс). Есть — обновляем только белый список.
- Block — ищем по (event, title).
- Category — ищем по (event, title). FK на Block пересчитывается
через карту «старый pk → Block на VPS».
- Performance — ищем по (event, participant_name, kind,
participant_number).
- Media — ищем по (event, storage_path).
5. Копирует previews/<slug>/ → PREVIEW_ROOT/<slug>/. Существующие
файлы с совпадающим размером не перезаписываются.
6. Удаляет staging и архив (флаги --keep-staging / --keep-archive).
Идемпотентность (проверено на практике):
- Повторный receive_event того же ивента: Event обновляется, Media
находит по (event, storage_path), ничего не дублируется.
- Превью с совпадающим размером пропускаются.
- Изменённые метаданные (перегенерированное превью, сдвиг времени) —
обновляются.
- Удалённые на ноуте Media не удаляются на VPS.
- Хозяин поля — VPS: лайки, покупки, заморозка, разметка, автор,
прайс, организатор, команда — при merge не трогаются.
4. Инфраструктура
4.1. Схема размещения
VPS — «витрина» (вне дома):
- Django + gunicorn + nginx + PostgreSQL.
- Статика + превью 600px + постеры видео + 10-сек mp4.
- Оплата (LiqPay / Fondy).
- Telegram-бот (один) — вход, уведомления, выдача ссылок.
- Модель DownloadToken.
- SSL Let's Encrypt. Домен znymkari.com → A → 128.140.81.213.
Ноут Олега — «склад + цех» (дома, белый IP):
- Оригиналы: единое хранилище на HDD.
- SSD: ОС + PostgreSQL + venv + превью на время push. Не хранилище контента.
- Пользователи: olegarh (uid 1000), deploy (uid 1001, sudo NOPASSWD).
- Hostname oleg-home, ядро 7.0.0-38. Ubuntu Server 24.04 LTS.
- Сеть: Wi-Fi wlp8s0, LAN 192.168.31.87 (резерв в роутере).
- Внешний SSH: 193.176.249.24:22022 → 192.168.31.87:22.
- Стек: Python 3.14, PostgreSQL 18, nginx 1.28, ffmpeg 8.0.1.
- STORAGE: /mnt/hdd/znymkari-storage/ — рабочая копия (slug-папки).
- FESTIVALS: /mnt/hdd/zny-festivals/ — оригиналы, источник импорта.
- HEAP: /mnt/hdd/zny-heap/ — свалка, вне импорта.
- Samba: юзер olegarh, порт 445, только LAN 192.168.31.0/24.
- Локальный Django + PostgreSQL (znymkari_local, uk_UA.UTF-8).
- Два проекта: web/ (сайт) и znymkari-data/ (цех). Отдельные git.
- znymkari-data — Django на 0.0.0.0:8000, systemd gunicorn-oleg.service.
- nginx на :443 — отдача файлов по одноразовой ссылке (план).
- Домен files.znymkari.com → A → 193.176.249.24 (план).
- Ноут = мини-UPS (2–4 ч).
4.2. Поток импорта (план)
- Олег копирует фестиваль с SD/диска в
\\192.168.31.87\zyn-festivals\<название>\. - В браузере на ноуте:
http://192.168.31.87:8000/→ Импорт → выбрать папку. znymkari-data— Django-командаimport_media: - Копирует оригиналы из/mnt/hdd/zny-festivals/<название>/в/mnt/hdd/znymkari-storage/<id>_<slug>/. - Sanitize имён (транслит + чистка). - Превью 600px + QR. - Постеры видео + 10-сек mp4. - ЗаписиMediaв локальную БД.pack_event <id>—pg_dump(только каталог) + tar превью →/tmp/staging/.push_event <id>— paramiko SFTP на VPS.receive_event <id>на VPS —pg_restoreв staging + merge (ON CONFLICT) + превью в/srv/previews/.
4.3. Поток выдачи файлов (план)
- Клиент платит на VPS →
order.status = paid. - VPS генерит
DownloadToken(UUID, 72 ч, лимит скачиваний). - Бот шлёт:
https://files.znymkari.com/d/<token>. - DNS резолвит в IP дома, клиент идёт напрямую.
- nginx дома
auth_request→ VPS по WireGuard «токен валиден?» → 200/403. - nginx отдаёт файлы напрямую клиенту.
VPS оригиналы не касается вообще.
4.4. Превью 600px
- Ширина 600px, JPEG,
quality=100. QR Znymkari в четырёх углах (высота 25%, отступ 20px, прозрачность 60%). - Генерация — на ноуте. Превью живут на VPS всегда — часть Схемы B.
- EXIF Orientation через
ImageOps.exif_transpose. - QR-файл:
media/znymkari_logo.jpg.
4.5. Видео
- Постер — кадр из 70% длительности. Ширина 600px, JPEG.
<имя>_poster.jpg. - 10-сек превью — mp4 H.264, ширина 854px, со звуком
(AAC 96 kbps),
-movflags +faststart. - Генерация —
import_service._generate_video_assets. Добор —python manage.py generate_video_posters <event_id> [--force]. - Отдача —
views.serve_video_previewчерез/catalog/video-preview/<path>.
4.6. Хранение
Всё содержимое живёт в едином хранилище /mnt/hdd/znymkari-storage/.
Оригиналы — в /mnt/hdd/zny-festivals/ (read-only для импорта, Олег
сам решает, когда удалять). Разделения HOT/COLD нет.
Путь файла: STORAGE_ROOT / media.storage_path.
Раскладка:
/mnt/hdd/znymkari-storage/ ├── 0001_vesnyana_feyeriya/ ├── 0002_... └── ...
Внутри <id>_<slug>/ — структура папок исходника, имена после sanitize.
NTFS-3G → NTFS3 — техдолг, отложен до покупки новых винчестеров. Сейчас работает FUSE-драйвер.
4.7. Чистка ивентов
Через год админ вручную удаляет лишнее. Остаётся ~30–40 фото.
- Удаляется: фото без лайков, не купленные, не замороженные. Видео — всё.
- Остаётся:
is_protected=True,is_purchased=True,likes_count > 0. - Эффект: ивент 200 ГБ → 1 ГБ.
- UI: кнопка «Удалить всё без лайков и покупок» в админке (план).
4.8. Бэкапы
| Что | Куда | Частота |
|---|---|---|
| БД ноута | Второй HDD / облако | Раз в неделю |
| БД VPS | На ноут + облако | Раз в неделю |
znymkari-storage |
Второй HDD | По необходимости |
zny-festivals |
Второй HDD | Раз в месяц |
| Превью (VPS) | Облако | Раз в месяц |
settings_local.py |
Локально + облако | При изменении |
Критично: не хранить бэкап на том же физическом диске.
5. Админский workflow
5.1. Импорт медиа
Полностью локальный на ноуте Олега. VPS-БД не трогается до push.
Перед запуском импорта проверяется свободное место: на HDD (с запасом 5%) и на SSD (оценка 15% от объема оригинала). Если места мало — импорт блокируется.
Многопоточность: обработка файлов идёт в ThreadPoolExecutor (3 потока). Это компромисс для USB HDD, чтобы избежать "thrashing".
Команда import_media <event_id> <festival_name> (в znymkari-data):
- Запускается на ноуте.
festival_name— имя подпапки внутриFESTIVALS_ROOT. - Источник:
/mnt/hdd/zny-festivals/<festival_name>/(read-only). - Назначение:
/mnt/hdd/znymkari-storage/<event.slug>/. - Оригинал в
zyn-festivals/остаётся — Олег сам решает, когда удалять. - Копирование с проверкой размера:
- Файла нет в назначении → копируем.
- Есть, размер совпал → пропускаем.
- Есть, размер не совпал → перезаписываем (считаем битым).
- Sanitize имён папок и файлов — транслит, структура сохраняется.
- Превью 600px с QR-водяным знаком — в
PREVIEW_ROOT/<slug>/<subfolder>/. - Постер видео + 10-сек mp4 — там же, рядом с превью фото.
- Метаданные из EXIF/ffprobe;
camera_key— ключ группировки камер;camera_offset_secondsнаследуется из существующихMediaтого же ивента. - Автор — эвристика: «фотограф»/«видеограф» в
TeamMember.role. - Дедупликация по
(event, storage_path)— проверкаexisting_pathsдо обработки каждого файла. - Идемпотентность: повторный запуск на том же ивенте →
skippedдля всех уже импортированных файлов.
Что НЕ делает import_media:
- Не пушит на VPS — это отдельная команда push_event.
- Не удаляет оригиналы.
- Не трогает БД VPS.
Копия import_media в web/ удалена 2026-10-16. На VPS импорт не нужен
(оригиналов нет), а на ноуте работает версия из znymkari-data.
Тесты импорта — только в znymkari-data. В web/ команды import_media
и generate_video_posters удалены, services/ тоже.
5.1-bis. Sanitize имён
Файлы и папки чистятся при копировании.
Правила (по порядку):
- Транслит кириллицы:
а→a,б→b,в→v,г→g,ґ→g,д→d,е→e,ё→e,є→ye,ж→zh,з→z,и→i,і→i,ї→yi,й→y,к→k,л→l,м→m,н→n,о→o,п→p,р→r,с→s,т→t,у→u,ф→f,х→kh,ц→ts,ч→ch,ш→sh,щ→shch,ъ→(пусто),ы→y,ь→(пусто),э→e,ю→yu,я→ya. Регистр сохраняется. - Пробел →
_. - Всё, что не
[a-zA-Z0-9._-]→_. - Схлопнуть
__+в_. - Обрезать
_по краям. - Пусто →
unnamed.
Примеры:
| Оригинал | После |
|---|---|
Камера 1 |
Kamera_1 |
Сцена №2 |
Stsena_2 |
Оля_фото |
Olya_foto |
Весняна феєрія (Коломия) |
Vesnyana_feyeriya_Kolomiya |
IMG_001.JPG |
IMG_001.JPG |
Структура папок сохраняется один в один — меняются только символы.
5.2. Панель часов камер /catalog/event/<id>/cameras/
Список устройств по camera_key. Слайдер ±30 минут, шаг 30 сек +
кнопки ±1 час. Двигает exact_datetime и end_datetime.
5.3. Админская разметка /catalog/event/<id>/markup/
App-shell: body: 100vh; overflow: hidden. Скроллится .timeline-scroll.
Шапка: «‹ Назад», название ивента + дата, статистика.
Тулбар: пагинация слева, кнопки справа (🧹 снять метки / 📷 камеры / 🖼 размер).
Колонки: Время | Медиа | В | К | Б | ❤ | 🔒.
Колонка лайков: красные, при 100+ — золотые. Размер шрифта 12–18px линейно от 0 до 100. При 0 — пусто.
Матрёшка: блок 2/6, категория 2/5, выступление 2/4.
Дефолтные имена в модалках: «Блок №N» / «Категория №N» / «Выступление №N». N = max+1. Ручные имена игнорируются.
5.4. Приложение apps.issues
Модель Issue — см. §3.9.
Админка: list_editable для status/priority. Фильтры. date_hierarchy.
Поиск. Кликабельная page_url_link. UserChoiceField — только is_staff=True.
Доступ: группа «Контент-менеджер» — add/change/view на 8 моделей, без delete.
5.5. Читалка доков /catalog/docs/<slug>/
URLs: 8 табов — architecture / state / context / changes / production /
setup-home / setup-vps / commits.
Доступ: @staff_member_required.
Рендер — markdown.markdown() с tables, fenced_code, toc, sane_lists.
Не кешируется.
6. Клиентские API
Все под /catalog/api/client/, без авторизации:
| URL | Что отдаёт |
|---|---|
years/ |
Список годов |
events/<year>/ |
Ивенты года + фото + цены |
event/<id>/photos/ |
N случайных превью |
search/?q=<строка> |
Поиск по названию и городу |
event/<id>/performances/ |
Выступления с ценами |
event/<id>/blocks/ |
Блоки |
event/<id>/categories/?block=<id> |
Категории |
event/<id>/performances-timeline/?block=&category= |
Выступления |
event/<id>/media/?offset=&limit=&level=&id=&block=&category= |
Медиа |
media/<id>/like/ (POST) |
Инкремент likes_count |
media/<id>/info/ |
Размеры оригинала |
media/<id>/large/ |
Копия 60% (кешируется) — план |
event/<id>/media-ids/?level=&id=&block=&category= |
Массив id + счётчики |
cart/details/ (POST) |
Группировка корзины |
favorites/details/ (POST) |
Группировка избранного |
cart/smart/ (POST) |
Умная корзина |
media/attach-performance/ (POST) |
Привязка награды |
Авторизованные:
| URL | Что |
|---|---|
favorites/ (GET) |
Список избранного |
favorites/add/ (POST) |
Добавить |
favorites/remove/ (POST) |
Убрать |
favorites/merge/ (POST) |
Слияние LS → БД |
favorites/to-cart/ (POST) |
Перенос |
cart/items/ (GET) |
Серверная корзина |
cart/items/add/ (POST) |
Добавить |
cart/items/remove/ (POST) |
Убрать |
cart/items/merge/ (POST) |
Слияние LS → БД |
cart/to-favorites/ (POST) |
Перенос |
Аккаунты:
| URL | Что |
|---|---|
/accounts/signup/ |
Регистрация |
/accounts/login/ |
Вход |
/accounts/logout/ |
Выход |
/accounts/activate/<uidb64>/<token>/ |
Активация |
/accounts/delete/ |
Удаление (soft) |
/accounts/profile/ |
Кабинет |
/accounts/update-profile/ |
Обновление |
/accounts/update-language/ |
Смена языка |
/accounts/telegram/init/ |
Токен |
/accounts/telegram/status/ |
Polling |
/accounts/telegram/confirm/ |
Из бота |
/accounts/telegram/finish/ |
Логин в браузере |
/accounts/telegram/unlink/ |
Отвязать |
Внутренние (по WireGuard, план):
| URL | Что |
|---|---|
POST /internal/check-token/?token=<...> |
Валидация DownloadToken для nginx дома |
POST /internal/large-preview/ |
60% оригинала с QR — генерация на ноуте |
POST /internal/receive-event/<id>/ |
Приём пакета от push_event |
Формат: JSON, {"status": "ok", ...}.
Временные поля: ISO с часовым поясом, 2026-05-23T11:45:56+03:00.
Лайки на клиенте: клик = delta += 1. Отправка при закрытии лайтбокса /
переключении / через 4 сек / beforeunload. Один POST — суммарный delta.
Сервер: F("likes_count") + delta, Greatest(..., 0).
Клиентские views (сайт):
| URL | Что |
|---|---|
/ |
Главная |
/event/<id>/ |
Ивент |
/catalog/prices/ |
Прайс |
/catalog/events/ |
Список ивентов |
/catalog/cart/ |
Корзина |
/catalog/favorites/ |
Избранное |
/catalog/preview/<path> |
Превью |
/catalog/video-preview/<path> |
Видео-превью |
/catalog/dev/ |
Ссылки (staff) |
/catalog/docs/<slug>/ |
Читалка доков (staff) |
Страницы znymkari-data (ноут):
| URL | Что |
|---|---|
/ |
Домашняя (кнопки Импорт · Хранилище · Ивенты · Настройки) |
/import/ |
Список ивентов + кнопка «Импортировать» |
/import/<id>/ |
Прогресс импорта |
/storage/ |
Инвентарь хранилища |
/settings/ |
Пути, VPS, ключи |
/admin/ |
Django-админка |
7. Структура кода
7.1. Два проекта
D:\content_shop\ ← локальный корень, без git
├── web\ ← сайт (git repo #1)
│ └── .git\ ← 43 коммита (git-filter-repo)
├── znymkari-data\ ← цех (git repo #2)
└── deploy.bat ← деплой сайта на VPS
Старый мир (cli\, domain\, scripts\, services\, storage\) удалён
2026-10-16. Всё нужное перенесено в web/ и znymkari-data/.
Реструктуризация выполнена 2026-10-15-б. Корневой .git упразднён,
история web/ переписана через git filter-repo --subdirectory-filter web/
(43 коммита, пути сокращены на один уровень). Бэкап старого репо:
G:\My Drive\backups\content_shop_2026-10-15\.
znymkari-data/ создан 2026-10-15-б (каркас Django, 10 моделей
каталога, миграции 0001–0008).
7.2. web/ — сайт
web/
├── manage.py, db.sqlite3, requirements.txt
├── start_all.bat, start_shell.bat, test.bat ← локальные скрипты
├── media/ ← Znymkari_logo, QR
├── docs/ ← все .md
├── static/
│ ├── css/common.css, css/lightbox.css
│ ├── js/common.js, lightbox.js, favorites.js, cart.js, merge-prompt.js
│ └── favicon.ico
├── config/
│ ├── settings.py, urls.py, wsgi.py, asgi.py
│ ├── settings_local.py (git-ignored)
│ ├── settings_local_prod.py (шаблон прод)
│ └── context_processors.py
└── apps/
├── catalog/
│ ├── models.py, admin.py, views.py, urls.py, api.py, client_api.py
│ ├── management/commands/ ← пусто (импорт — только на ноуте)
│ ├── templates/catalog/
│ │ ├── home.html, event.html, cart.html, favorites.html
│ │ ├── events_list.html, prices.html, markup.html
│ │ ├── cameras.html, dev_links.html, docs.html
│ │ └── includes/
│ │ ├── footer.html, sitemap.html
│ │ ├── header_actions.html, buy_modal.html
│ ├── tests.py
│ └── migrations/ (10)
├── accounts/
│ ├── models.py, admin.py, views.py, views_telegram.py
│ ├── urls.py, forms.py, tokens.py, signals.py, tests.py
│ ├── templates/accounts/profile.html
│ └── migrations/ (4)
├── issues/
│ ├── models.py, admin.py
│ ├── tests/
│ └── migrations/
├── telegram/
│ ├── api.py, services.py, messages.py, keyboards.py
│ ├── preview_photo.py, bot.py, models.py
│ ├── views.py
│ ├── handlers/
│ ├── management/commands/
│ │ ├── run_telegram_bot.py (disabled)
│ │ └── set_telegram_webhook.py
│ └── migrations/
└── orders/ ← пусто, Этап 3
7.3. znymkari-data/ — цех (каркас создан 2026-10-15-б)
znymkari-data/
├── manage.py, requirements.txt
├── config/
│ ├── settings.py, urls.py, wsgi.py, asgi.py
│ └── settings_local.py (не в git)
├── apps/
│ ├── catalog/ ← модели каталога
│ │ ├── models.py (Event, Block, Category, Performance, Media,
│ │ │ Organizer, TeamMember, ProgramScan, Expense, ExtraIncome)
│ │ ├── admin.py
│ │ └── migrations/
│ ├── importer/ ← импорт
│ │ ├── sanitize.py ✅
│ │ ├── media_metadata.py ✅
│ │ ├── import_service.py ✅ (import_festival)
│ │ ├── views.py ⏳ ← /import/, /import/<id>/
│ │ ├── urls.py ⏳
│ │ ├── models.py ⏳ ← ImportJob
│ │ └── management/commands/
│ │ ├── import_media.py ✅
│ │ ├── generate_video_posters.py ✅
│ │ ├── pack_event.py ⏳
│ │ └── push_event.py ⏳
│ └── dashboard/ ← мини-админка для Олега
│ ├── views.py ⏳ ← /, /settings/, /storage/
│ └── urls.py ⏳
├── templates/
│ ├── base.html
│ ├── dashboard/index.html, settings.html
│ └── importer/import_list.html, import_progress.html, storage.html
└── static/
└── css/style.css ← минимальный
7.4. Две конфигурации web/
| Машина | settings_local.py |
БД |
|---|---|---|
| VPS | Postgres znymkari_db, DEBUG=False, ALLOWED_HOSTS=['znymkari.com', 'www.znymkari.com'] |
Прод |
| Dev (Win) | SQLite или Postgres znymkari_local, DEBUG=True |
Локально |
Прод-шаблон — settings_local_prod.py. На VPS копируется в
settings_local.py. НЕ в git, НЕ в deploy.bat.
7.5. Логирование
config/settings.py — LOGGING с RotatingFileHandler (10 МБ × 5).
Файл /var/log/znymkari/django.log. Отдельный logger для
django.security.DisallowedHost (propagate: False).
На Linux — файл + console. На Windows (dev) — только console.
7.6. Правило синхронизации моделей
Event, Block, Category, Performance, Media, Organizer,
TeamMember, ProgramScan, Expense, ExtraIncome —
один источник истины: web/apps/catalog/models.py.
При добавлении поля:
1. Правится в web/.
2. Миграция применяется на VPS.
3. Копируется в znymkari-data/apps/core/models.py.
4. Миграция применяется на ноуте.
Разъезд — фатален для pg_restore. Проверять diff перед push.