Znymkari · Лабораторія
←

Документация

Архитектура Статус Контекст История Продакшн Ноут Олега VPS COM Oleg COM Pavel Коммиты VPS Коммиты Цеха

ARCHITECTURE.md — ТЗ проекта

Как должно быть. Бизнес-логика, сценарии, модели, API, инфраструктура. Статус — STATE.md. История — CHANGES.md. Чек-лист — PRODUCTION.md. Оборудование для Олега — SETUP_HOME.md. Команды — COMMANDS.md.

Формат сессий: только даты в CHANGES.md (2026-10-14, 2026-10-14-б). В остальных доках — без номеров сессий.


0. Стандарт оформления страниц

Дефолтный каркас любой страницы:

  1. Фон — градиент + 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.

  1. Шапка — заголовок по центру. Слева — «Назад» + «Вверх».
  2. Футер — единый, {% include "catalog/includes/footer.html" %}.
  3. Карта сайта — pull-up панель, {% include "catalog/includes/sitemap.html" %}.
  4. <body data-event-id="..."> — обязательный атрибут.
  5. Цвета: розовый #ff5ebc, тёмный фон #0a0a12, серые #888 / #555.

Кнопка «Назад» — setupBackLink в common.js. Есть история → history.back(). Иначе — на /.


1. Суть бизнеса

Видеограф/фотограф снимает конкурсы, фестивали, концерты. Сайт-магазин показывает контент «товаром лицом», продаёт поштучно и оптом, автоматизирует выдачу после оплаты.

Ключевые решения:

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. Роли пользователей

1.3. Типы клиентов


2. Пользовательские сценарии

2.0. Воронка клиента

  1. Гость заходит. Ищет «своих» через поиск и таймлайны. Отбирает в Избранное (localStorage).
  2. Жмёт «Как купить?» (модалка-инструкция).
  3. Регистрация (Telegram — основной, email — запасной).
  4. На входе — merge-промпт, если есть выбор гостя.
  5. Залогиненный переносит медиа между Избранным и Корзиной.
  6. В корзине — Умная корзина (допродажи, пакеты, скидки).
  7. «Купить» → оплата.
  8. После оплаты — бот шлёт одноразовую ссылку https://files.znymkari.com/d/<token>.
  9. Клиент качает файлы напрямую с ноута Олега.
  10. Файлы в БД: 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→🔖] · Просмотр.

Режим «Просмотр» — включён по умолчанию. Синий фон галереи, клик по тайлу → лайтбокс.

Галерея: сетка 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. Авторизация, регистрация, профиль

Два способа:

  1. Telegram (основной). Один клик. Бот @Znymkari_dev_bot (id 8807489402, dev).
  2. 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. Поток импорта (план)

  1. Олег копирует фестиваль с SD/диска в \\192.168.31.87\zyn-festivals\<название>\.
  2. В браузере на ноуте: http://192.168.31.87:8000/ → Импорт → выбрать папку.
  3. znymkari-data — Django-команда import_media: - Копирует оригиналы из /mnt/hdd/zny-festivals/<название>/ в /mnt/hdd/znymkari-storage/<id>_<slug>/. - Sanitize имён (транслит + чистка). - Превью 600px + QR. - Постеры видео + 10-сек mp4. - Записи Media в локальную БД.
  4. pack_event <id> — pg_dump (только каталог) + tar превью → /tmp/staging/.
  5. push_event <id> — paramiko SFTP на VPS.
  6. receive_event <id> на VPS — pg_restore в staging + merge (ON CONFLICT) + превью в /srv/previews/.

4.3. Поток выдачи файлов (план)

  1. Клиент платит на VPS → order.status = paid.
  2. VPS генерит DownloadToken (UUID, 72 ч, лимит скачиваний).
  3. Бот шлёт: https://files.znymkari.com/d/<token>.
  4. DNS резолвит в IP дома, клиент идёт напрямую.
  5. nginx дома auth_request → VPS по WireGuard «токен валиден?» → 200/403.
  6. nginx отдаёт файлы напрямую клиенту.

VPS оригиналы не касается вообще.

4.4. Превью 600px

4.5. Видео

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 фото.

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):

Что НЕ делает 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 имён

Файлы и папки чистятся при копировании.

Правила (по порядку):

  1. Транслит кириллицы: а→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. Регистр сохраняется.
  2. Пробел → _.
  3. Всё, что не [a-zA-Z0-9._-] → _.
  4. Схлопнуть __+ в _.
  5. Обрезать _ по краям.
  6. Пусто → 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.