106 Commits
Author SHA1 Message Date
erjemin 70aa6afe6d mod: проверка статей-хабов (2) и заглушки для хабов. 2026-08-12 00:15:11 +03:00
erjemin f477b5f8f8 minor: добавлен слаг в list_display 2026-08-12 00:13:26 +03:00
erjemin 4b454e3f56 mod: проверка статей-хабов 2026-08-11 22:27:06 +03:00
erjemin 7a009ff2dc mod: улучшено 404 (2) 2026-08-11 22:25:40 +03:00
erjemin 11cff322e2 minor: стили заголовков 2026-08-11 22:24:20 +03:00
erjemin 903a16373e add: Архитектура исполнения DSL и Реестр обработчиков (DSL Query Registry) 2026-08-09 23:30:48 +03:00
erjemin 215d1f286d minor: Важные комментарии к ArticleType + косметические правки 2026-08-09 23:28:16 +03:00
erjemin 626bf19a9e add: роут и вьюха для HUB (драфт 1) 2026-08-09 23:23:12 +03:00
erjemin 80c56480c6 minor: --- 2026-08-09 22:05:31 +03:00
erjemin b791a5cee9 mod: улучшено 404 2026-08-09 22:05:00 +03:00
erjemin 56e850979d add: 404 2026-08-09 20:29:01 +03:00
erjemin 3ce7214515 mod: описание DSL + синтаксис для фильтрации и комбинированных блоков 2026-08-08 22:13:07 +03:00
erjemin d3f64843b2 minor: По умолчанию типограф выключен 2026-08-08 20:49:38 +03:00
erjemin fe7554a9f6 add: Шаблон для статьи + стили 2026-08-08 20:49:05 +03:00
erjemin 74ce03b86e add: роутинг, вьюха и шаблон для отображения статьи... +слой стилей TileWind для контента их базы. 2026-08-08 17:46:36 +03:00
erjemin edad40654c minor: TXT заменил на INFO (включая роутинг, шаблоны и вьюхи) 2026-08-08 17:35:46 +03:00
erjemin 23699f1af4 del: prettier-plugin-tailwindcss -- хоть и даёт красивое расположения классов, зато все осатльное тоже "красивое", что просто боль... 2026-08-08 01:11:27 +03:00
erjemin a2de98bc7a minor: форматирование 2026-08-08 01:04:41 +03:00
erjemin 77443959eb add: prettier-plugin-tailwindcss для красивого расположения классов 2026-08-07 21:39:38 +03:00
erjemin 14e3ef7344 add: планы (1) 2026-08-07 17:14:38 +03:00
erjemin 5379efffaa nod: описание DSL в чати HUB 2026-08-07 17:14:10 +03:00
erjemin 1068fd850c fix: корректные ссылки /info 2026-08-06 00:06:52 +03:00
erjemin b522d9c9de add: роутинг и шаблон для раздела "информация" (список). 2026-08-05 22:27:56 +03:00
erjemin 77af2ad3ac add: планы (ближние и дальние (1) 2026-08-05 22:24:12 +03:00
erjemin d30eaed3a6 add: js-счетчиков (подклбючяются если дано согласие на отслеживание) 2026-08-05 22:23:49 +03:00
erjemin 2ca07d24ff minior: дизайн: ширина и отсттупы 2026-08-05 06:50:29 +03:00
erjemin 13dd969a60 tmp: верстка "Политика безопасности и конфиденциальности LPON.RU" 2026-08-04 21:11:40 +03:00
erjemin 4c53b170d9 mod: .gitignore для базы 2026-08-04 19:55:18 +03:00
erjemin 74a320f6c7 mod: админка (20) поля i_article_sort и j_article_metadata в ArticleAdmin и ArticleAdminForm 2026-08-03 23:15:02 +03:00
erjemin 9203fdbfc4 add: статический типизатор pyrefly 2026-08-03 23:04:21 +03:00
erjemin 68382692de mod: модели (19) добавлены поля j_article_metadata и i_article_sort для статей + расширен список ArticleType 2026-08-03 23:02:47 +03:00
erjemin f6bd5c5861 mod: доки по роутингу (драфт 1) 2026-08-03 07:09:33 +03:00
erjemin 50543fa8ee add: доки по корзине о обработке заказов без хранения ПДн (драфт) 2026-08-03 00:48:10 +03:00
erjemin f3186ff28f add: доки по роутингу (драфт) 2026-08-03 00:38:25 +03:00
erjemin ee0235d68b mod: описание DSL (draft 2) 2026-08-02 20:46:37 +03:00
erjemin c1ee3ae930 add: описание DSL (draft 1) 2026-08-02 18:12:58 +03:00
erjemin ff1369f200 fix: front (10) @import "tailwindcss" в дев не нужен (вызывавет 404) 2026-08-01 18:45:19 +03:00
erjemin 7fe2cbed53 add: front (9) хлебные крошки 2026-07-30 21:42:48 +03:00
erjemin c5a9877273 add: front (8) нижний popup об куках и отслеживании 2026-07-28 22:23:00 +03:00
erjemin f3ed126072 mod: контекстный-процессор теперь передает не только IS_DEBUG, но и ALLOW_TRACKING (куки, что клиент согласился на отлеживание через Google Tag Manager, Яндекс.Метрика и т.п.) 2026-07-28 21:10:04 +03:00
erjemin 7a9d5c4056 add: front (7) иконка "домик" для хлебных крошек 2026-07-28 00:01:54 +03:00
erjemin 7deaf34daf mod: front (6) сборка TialWindCSS и кастомыне base и слои 2026-07-27 21:49:13 +03:00
erjemin 6c3c440fe1 add: контекстный-процессор для debug 2026-07-26 23:01:09 +03:00
erjemin 677fb7a878 mod: front (5) шаблон шапки (драфт 02) уменьшается при прокручивании 2026-07-25 22:01:30 +03:00
erjemin ad4f176209 add: front (4) шаблон шапки (драфт 01) + главная страница (драфт 00) 2026-07-25 20:41:36 +03:00
erjemin 18ff09fe3d minor: переименование 2026-07-23 23:16:25 +03:00
erjemin 4dd34d5550 tho: идеи слоганов 2026-07-23 23:15:52 +03:00
erjemin 60be891504 add: front (3) шаблон верхнего уровня _base.html 2026-07-22 00:10:59 +03:00
erjemin cf920ca65b minor: front (2) Сплеш-скрин index (+ оптимизированные логотипы) 2026-07-19 14:43:09 +03:00
erjemin d99c59718e add: front (1) Сплеш-скрин index 2026-07-19 02:00:19 +03:00
erjemin e125146468 mod: более новая версия TailWindCSS + Скрпит-сборщик с новым input.css внутри (чтобы собиралось только то, что нужно для проекта!) 2026-07-19 00:00:42 +03:00
erjemin 9df635e4de add: готовый htmx 2026-07-18 18:06:34 +03:00
erjemin 83cdd5625f add: NPM-сборщик htmx, пакеджи и скрипт для запуска сборки 2026-07-17 21:21:49 +03:00
erjemin 6347f7592a add: Собранный alpine.js 2026-07-17 21:16:37 +03:00
erjemin 14eb5c224b add: NPM-сборщик Alpine.js и скрипт для запуска сборки 2026-07-17 21:16:05 +03:00
erjemin 4c78b5e976 add: Готовый TailWindCSS 2026-07-17 20:17:24 +03:00
erjemin e625493587 add: NPM-сборщик TailWindCSS и скрипт для его запуска 2026-07-17 20:15:52 +03:00
erjemin fbf13daabb minor: блокируем попадание ошметков сборки NPM в репозиторий (на случай падений сборщиков или прерываний по <Ctrl+C>) 2026-07-17 20:14:20 +03:00
erjemin dd2c57d42a add: В доки добавлено инструкции по подключению CodeMirror с помощью миксина CodeMirrorFormMixin 2026-07-17 19:23:34 +03:00
erjemin 5b911007de minor: сборщик CodeMirror и доки поменяли свои каталоги (подготовка к NPM для TailWindCSS) + в доки и скрипты внесены соответствующие правки. 2026-07-17 19:17:04 +03:00
erjemin ef7a37fa6a doc: Архитектура парсера и валидации данных (драфт) 2026-07-16 00:22:50 +03:00
erjemin 3bc00d90bb doc: Поток данных для парсинга (драфт) 2026-07-16 00:17:58 +03:00
erjemin 84423c0f56 add: Организация справочника музыкальных стилей 2026-07-16 00:07:48 +03:00
erjemin 83d0df0b15 del: план переделки связей картинок неактуален (сделано по-другому, см. комментария в models.py) 2026-07-15 23:22:54 +03:00
erjemin 7e5b69c83b doc: Паттерн создания справочников 2026-07-15 23:20:34 +03:00
erjemin edd339dc82 doc: Как создается и как управлять offer_code для скрытия ID в URL и QR-кодов (2) + Django Custom Command для проверки и перегенерации 2026-07-15 22:21:00 +03:00
erjemin d221f5e626 add: Django Custom Command (1) проверка и перегенерация offer_code (нужно при смене соли) 2026-07-15 22:10:32 +03:00
erjemin 9fe12c4ab7 doc: Как создается и как управлять offer_code для скрытия ID в URL и QR-кодов. 2026-07-15 21:41:51 +03:00
erjemin f963d21b26 fin: модели (18) полные комментарии с подробным описанием полей и механики цепочки методов, когда save одной модели создает или изменяет записи в других моделях... 2026-07-15 21:15:34 +03:00
erjemin b9c4cc0e68 mod: модели (17) комментарии с подробным описанием полей и механики цепочки методов, когда save одной модели создает или изменяет записи в других моделях... 2026-07-15 21:02:07 +03:00
erjemin e4c1e920bb mod: валидатор форм, парсера и моделей (30) хелпер render_image_thumbnail применен к ItemAdmin, SellerAdmin, LabelAdmin иArtistAdmin и MusicStyleAdmin 2026-07-15 19:34:38 +03:00
erjemin 79df8de977 mod: валидатор форм, парсера и моделей (29) хелпер render_image_thumbnail применен к OfferAdmin 2026-07-15 18:51:47 +03:00
erjemin b3d47aca52 mod: валидатор форм, парсера и моделей (28) подготовка миниатюр для списков вынесено в хелпер render_image_thumbnail и применен к ImageMetadataAdmin и ArticleAdmin 2026-07-15 18:23:50 +03:00
erjemin 9ba5ef2af6 mod: minor: валидатор форм, парсера и моделей (28) миграции и переименование поля s_offer_skip32 в s_offer_code в модели TbOffer 2026-07-15 17:20:54 +03:00
erjemin a2986cf719 mod: minor: валидатор форм, парсера и моделей (27) формы OfferHistoryAdminForm и админка OfferHistoryAdmin + поле t_history_created в модели TbOfferHistory теперь реадктируемое но с авто-now через default 2026-07-15 16:40:44 +03:00
erjemin be5116dac0 fix: модели (16) default для JSON-объекта — это mutable object. Django будет переиспользовать один и тот же словарь для всех объектов модели. Если один объект изменит его содержимое, это повлияет на все остальные. Решение: использовать callable-функцию, которая вернет новый словарь для каждого экземпляра 2026-07-15 00:58:56 +03:00
erjemin 61cf1d61f2 mod: валидатор форм, парсера и моделей (27) в save модели TbOffer автоматически обновляет историю цен в TbOfferHistory (добавляя записи) 2026-07-14 00:30:28 +03:00
erjemin 460adbb65b minor: валидатор форм, парсера и моделей (26) в save модели TbOffer автоматически создаем хаотичные коды для скрытия id (чтобы злоумышленники не проворачивали махинации с заказами и покупками. Код товара выглядит хаотично и не поддается расшифровки без секретного ключа). 2026-07-13 23:48:27 +03:00
erjemin 9bd181b207 minor: валидатор форм, парсера и моделей (25) админка OfferAdmin и формы OfferAdminForm для модели TbOffer "одеты" с CodeMirror 2026-07-13 16:47:18 +03:00
erjemin 0fc87fa46b minor: валидатор форм, парсера и моделей (25) значения по умолчанию для типографа 2026-07-12 23:09:53 +03:00
erjemin 140966a830 minor: валидатор форм, парсера и моделей (24) подключен etpgf-типограф для html-полей и добавлены виртуальные поля для управления режимом типотграфирования 2026-07-12 22:15:31 +03:00
erjemin 0681c3d67b minor: валидатор форм, парсера и моделей (23) немного улучшен код, подготовлено для подключения типографа etpgrf для html-полей 2026-07-12 00:32:16 +03:00
erjemin f43af593ce add: валидатор форм, парсера и моделей (22) проверяем и подсказываем, что нужно изменить slug 2026-07-12 00:04:32 +03:00
erjemin 472eae382d minor: валидатор форм, парсера и моделей (21) убрали нумерацию строк для SEO-полей в админке статей 2026-07-11 11:55:17 +03:00
erjemin 30eb50dde6 mod: валидатор форм, парсера и моделей (20) автоматически устанавливаем ALT и TITLE для картинок для статей (расширили save в модели TbArticle и он проверяет и изменяет поля картинко в django_filer) 2026-07-11 11:54:09 +03:00
erjeminandCopilot ee45d3a2fa mod: валидатор форм, парсера и моделей (19) автоматически устанавливаем дефолтные метаданные к связанным картинкам. Переопрелен метод save для модели TbArticle и проброс него request из ArticleAdmin через переопределенный save_model...
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-10 23:52:54 +03:00
erjemin 286dd8eddc mod: админка (19) миксин CodeMirrorFormMixin применен для админки статей ArticleAdminForm и ArticleAdmin + расширение стилей 2026-07-10 21:28:39 +03:00
erjemin bd96468dd5 mod: модели (15) переделка логики подключения картинок (3) модель TbImageMetadata (бывшая TbImage) стала промежуточной таблицей m2m для связи TbOffer с картинками из filer.Image + удаление ненужных полей из TbImageMetadata + миграции 2026-07-10 16:56:34 +03:00
erjemin 8ea088e26c mod: модели (14) переделка логики подключения картинок (2) поле FilerImageField в модели TbArticle + fieldsets в админке ArticleAdmin 2026-07-10 12:01:02 +03:00
erjemin ed40ed1b74 mod: модели (13) переделка логики подключения картинок (1) 2026-07-10 11:12:47 +03:00
erjemin af3a2c6690 minor: план переделки работы с картинками! Через сигналы! Уточнено. 2026-07-09 18:14:21 +03:00
erjemin f281997afc minor: план переделки работы с картинками! Через сигналы! 2026-07-07 22:24:06 +03:00
erjemin 70137a6012 monor: валидатор форм, парсера и моделей (18) комментарии 2026-07-07 11:26:47 +03:00
erjemin 67aad9ab11 mod: валидатор форм, парсера и моделей (17) подключены хелперы автоматического создания статей к исполнителям (модель TbSeller и админке SellerAdmin). Проверка синонимов для продавцов не нужна. 2026-07-02 19:17:52 +03:00
erjemin 19e812e231 minor: переименован ключ для синонимов в метаданных ("SYNONYM" -> "KEY_SYNONYM_EN") и переменная для него (KEY_SYNONYM -> KEY_SYNONYM_EN) 2026-07-02 19:13:53 +03:00
erjemin f6267e1e6c fix: валидатор форм, парсера и моделей (16) устранены проблемы с ватчером изменений в формах (перекрытие JS и CSS + потенциальные проблемы с порядком загрузки JS) 2026-07-02 16:13:49 +03:00
erjemin ca769be110 mod: валидатор форм, парсера и моделей (15) все хелперы валидации и автоматического создания статей подключены музыкальным стилям (модель TbMusicStyle, формам MusicStyleAdminForm и админке MusicStyleAdmin) 2026-07-02 15:17:07 +03:00
erjemin 9522d3fe69 mod: валидатор форм, парсера и моделей (14) все хелперы валидации и автоматического создания статей подключены исполнителям (модель TbArtist, формам ArtistAdminForm и админке ArtistAdmin) 2026-07-01 21:12:27 +03:00
erjemin 429d2fb417 minor: модели (12) сброшены миграции 2026-07-01 19:21:32 +03:00
erjemin 6dd2254d2c minor: модели (11) переименовано поле j_style_synonyms в j_label_metadata для однообразности, добавлено поле для "паредзаказов" и еще по мелочи... 2026-07-01 18:21:00 +03:00
erjemin 670bfb8c75 minor: админка (18) миксин CodeMirrorFormMixin применен для админки картинок TbImageAdminForm 2026-07-01 15:05:30 +03:00
erjemin 22d912608b minor: админка (17) типизация cleaned_data в валидаторе формы "Лейблы" 2026-07-01 13:01:09 +03:00
erjemin c6d2c388e6 mod: админка (16) подключение CodeMirror в формах вынесено в отдельный миксин 2026-06-30 20:13:12 +03:00
erjemin acc7662faf add: TODO про сообщении парсера о создании статьи в Redis. 2026-06-30 19:44:30 +03:00
erjemin 855017a0b6 mod: в функциях корректно установлены значения по умолчанию и добавлены внутренние валидаторы наличия полей в моделях. 2026-06-30 19:35:38 +03:00
erjemin be673483ae mod: функции валидации вынесены в отдельный файл utils_validators.py 2026-06-30 18:40:35 +03:00
70 changed files with 10374 additions and 2012 deletions
+13
View File
@@ -8,6 +8,19 @@ DJANGO_ADMINS=YouName:you@email.com
# Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом gunicorn) # Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом gunicorn)
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://testserver,https://lpon.ru DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://testserver,https://lpon.ru
# ====== HASHIDS настройка для s_offer_skip32 ======
# Используется для кодирования ID коммерческих предложений в компактные, хаотичные коды (не криптографический хеш, а encoder).
# - OFFER_HASHIDS_SALT: секретная соль для кодирования (ВАЖНО! Менять для production!)
# Только с солью можно декодировать, без нее невозможно восстановить ID
# Генерируйте криптографически стойкую случайную строку: python -c "import secrets; print(secrets.token_hex(16))"
# Содержит только буквы и цифры (безопасно для URL и QR-кода)
# - OFFER_HASHIDS_MIN_LENGTH: минимальная длина кода
# 6 символов = оптимально для каталогов до 100k коммерческих предложений
# 8 символов = для каталогов 100k-10M коммерческих предложений
# Рекомендация: оставить 6 для начальной фазы, поменять на 8 при росте
OFFER_HASHIDS_SALT=your-super-secret-salt-for-production-change-me-now
OFFER_HASHIDS_MIN_LENGTH=6
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
DJANGO_ADMIN_URL=admin/ DJANGO_ADMIN_URL=admin/
+10 -1
View File
@@ -367,4 +367,13 @@ public/static_collected/
.github/ .github/
# Data Backup # Data Backup
database/ *.sql
**.sql.gz
*.sqlite*
# Frontend assembly builds
frontend-assembly/*/node_modules/
frontend-assembly/*/src/
frontend-assembly/*/.env
node_modules/
+435
View File
@@ -0,0 +1,435 @@
## ПОТОК ДАННЫХ (чистая архитектура) ДРАФТ
```text
1️⃣ ИСТОЧНИК ДОБАВЛЕН
├─ Пользователь добавляет новый источник (TbSource)
└─ Создается задание в Redis: `job:parse_source:123`
2️⃣ ФОНОВЫЙ ВОРКЕР (Celery/APScheduler)
├─ Видит задание в Redis
├─ Начинает парсить (не пишет в prod БД!)
├─ Получает: Style["Rock"], Artist["The Beatles"], Label["Sony"]
├─ Кидает КАЖДЫЙ в Redis как **виртуальный объект**
│ └─ `pending:style:1` → {"title": "Rock", "source": "Discogs", ...}
│ └─ `pending:artist:2` → {"title": "The Beatles", "source": "MusicBrainz", ...}
└─ Смотрит: есть ли в product БД? Если НЕТ → в Redis очередь
3️⃣ АДМИНКА ДЖАНГО (виртуальная вкладка "Ожидающие задачи")
├─ Админ видит в Redis список
├─ Видит: Rock (похож на existing Rock?), The Beatles (новый?), Sony (уже есть)
├─ **ПОТЫКАЛ**:
│ ├─ Rock → "Мержить с existing Rock" → задание в Redis
│ ├─ The Beatles → "Одобрить" → задание в Redis
│ └─ Sony → "Пропустить" → удалить из Redis
└─ Каждое действие → новое задание в Redis
4️⃣ ФОНОВЫЙ ВОРКЕР II (финальный штрих)
├─ Видит задание: "merge Rock с ID:5"
│ └─ Делает: добавляет синонимы, сохраняет в product БД
├─ Видит задание: "create The Beatles"
│ └─ Пишет вреальную БД → TbArtist создана
└─ После каждого успеха → **удалить из Redis**
5️⃣ ПРОДАКТ БД (в итоге)
└─ Только валидные, одобренные, обработанные данные
└─ No garbage, ID подряд
```
# Архитектура
```text
Парсер Redis (очередь) Админка Воркер БД
│ │ │ │ │
├──parse_source───────────→│ │ │ │
│ │ │ │ │
│ ┌─pending:style:1 │ │ │
│ ├─pending:artist:1 ←─ Админ видит видит здесь │ │
│ └─pending:label:1 потыкает кнопки │ │
│ │ │ │ │
│ │ "merge Rock"│ │ │
│ │←───────────────────│ →─ job:merge:1 ──────→│ │
│ │ │ ├─→ UPDATE Style →│
│ │ │ │ delete from Redis
│ │ "create Beatles" │ │
│ │←──────────────────→─ job:create:2 ────────→│ │
│ │ │ ├─→ INSERT Artist ─│
│ │ │ │ delete from Redis
│ │ │ │ │
└───────────────────────────────────────────────────────────────────────────────────────────────────→
```
---
## КЛЮЧЕВЫЕ ПРИНЦИПЫ
- **Чистая БД**: product база получает только одобренные, валидные данные
- **ID подряд**: мнимизирукет удаления (delete), INSERT данных парсинга только при одобрении
- **Single Source of Truth**: пока не одобрено администратором → данные ТОЛЬКО в Redis
- **Асинхронность**: парсер не блокирует админку, админка не блокирует парсер
- **Откат дешевый**: удалить из Redis дешевле, чем восстанавливать из БД
---
## КОМПОНЕНТЫ REDIS ОЧЕРЕДИ
```
tasks:pending ← Очередь неодобренных задач (парсер → сюда)
└─ pending:style:1
└─ pending:artist:2
└─ pending:label:3
tasks:approved ← Очередь одобренных (админ → сюда)
└─ {type: 'create', id: 'style:1', data: {...}}
└─ {type: 'merge', id: 'artist:1', merge_with_id: 5}
└─ {type: 'skip', id: 'label:3'}
tasks:completed ← История завершённых (воркер → сюда)
tasks:failed ← История ошибок (воркер → сюда)
```
---
## ДЕЙСТВИЯ В АДМИНКЕ (виртуальная вкладка)
Админ видит Redis очередь и кликает:
1. **"✅ Одобрить"** → `job:create:style:1` → Воркер пишет в БД
2. **"🔗 Мержить с ID:5"** → `job:merge:style:1:with:5` → Воркер обновляет existing
3. **"❌ Пропустить"** → Удалить из Redis (ничего не пишется)
4. **"📝 Отредактировать"** → Изменить JSON в оптимистичной форме → сохранить как новое задание
---
## ПАРСЕР (упрощённо)
```python
def parse_source(source_id):
source = TbSource.objects.get(id=source_id)
for style_name in PARSED_STYLES:
# 1. Ищем существующий стиль или по названию или по алиасам
existing = TbMusicStyle.objects.filter(
Q(s_style_name__iexact=style_name) |
Q(j_style_synonyms__contains=style_name)
).first()
if existing:
# ✅ МАТЧИНГ СРАБОТАЛ → пишем сразу в БД
if style_name not in existing.j_style_synonyms:
existing.j_style_synonyms.append(style_name) # Запомнили синоним!
existing.save()
else:
# ❌ НОВЫЙ СТИЛЬ → в Redis очередь на одобрение
redis.lpush('tasks:pending', {...})
# В метаданных источника сохраняем прогресс парсинга
source.j_source_metadata['last_parsed_line'] = current_row_number
source.j_source_metadata['total_lines'] = total_rows
source.j_source_metadata['parsed_at'] = timezone.now().isoformat()
source.save()
```
---
## АДМИНКА (упрощённо)
```python
class PendingTaskAdmin(admin.ModelAdmin):
# Не наследуем от ModelAdmin (нет моделей!)
# Вместо этого: читаем Redis, рендерим как таблицу
def changelist_view(self, request):
# Получаем из Redis
tasks = [json.loads(redis.get(key)) for key in redis.keys('pending:*')]
# Выводим нетипичную таблицу с кнопками: Одобрить, Мержить, Пропустить
```
---
## ФОНОВЫЙ ВОРКЕР (упрощённо)
```python
def process_approved_tasks():
while True:
task = redis.lpop('tasks:approved')
try:
if task['type'] == 'create':
# Пишем в product БД
TbMusicStyle.objects.create(title=task['title'], ...)
elif task['type'] == 'merge':
# Обновляем existing + добавляем синонимы
style = TbMusicStyle.objects.get(id=task['merge_with_id'])
style.j_style_synonyms.append(task['title'])
style.save()
# Успех → удалить из Redis
redis.delete(task['_redis_key'])
redis.lpush('tasks:completed', task)
except Exception as e:
redis.lpush('tasks:failed', {**task, 'error': str(e)})
```
---
## ТЕХНОЛОГИЧЕСКИЙ СТЕК
- **Redis** — очередь + кэш виртуальных объектов
- **Celery** или **APScheduler** — фоновый воркер для парсера и финального сохранения
- **Django Admin** — кастомная вкладка для управления очередью
- **PostgreSQL/SQLite** — product database (только одобренные данные)
---
## ВЕТКА: ПРЯМОЕ ПОПАДАНИЕ В БД (когда матчинг сработал)
Если парсер нашел алиас или группа уже в БД → **обходим очередь**, пишем сразу:
```python
def parse_source(source_id):
source = TbSource.objects.get(id=source_id)
for style_name in PARSED_STYLES:
# 1. Ищем существующий стиль или по названию или по алиасам
existing = TbMusicStyle.objects.filter(
Q(s_style_name__iexact=style_name) |
Q(j_style_synonyms__contains=style_name)
).first()
if existing:
# ✅ МАТЧИНГ СРАБОТАЛ → пишем сразу в БД
if style_name not in existing.j_style_synonyms:
existing.j_style_synonyms.append(style_name) # Запомнили синоним!
existing.save()
else:
# ❌ НОВЫЙ СТИЛЬ → в Redis очередь на одобрение
redis.lpush('tasks:pending', {...})
# В метаданных источника сохраняем прогресс парсинга
source.j_source_metadata['last_parsed_line'] = current_row_number
source.j_source_metadata['total_lines'] = total_rows
source.j_source_metadata['parsed_at'] = timezone.now().isoformat()
source.save()
```
---
## ИЗМЕНЕНИЕ ЦЕН (дополнительная ветка)
Когда уже существующий товар поменял цену → в отдельную очередь:
```
tasks:price_changes ← Отдельная очередь
└─ {type: 'price_update', offer_id: 42, old_price: 100, new_price: 120}
└─ {type: 'price_update', offer_id: 43, old_price: 200, new_price: 180}
```
**Обработка:**
- Фоновый воркер видит изменение цены
- Проверяет: изменилась ли существенно (> 5%)?
- Если да → может требоваться одобрение (+1 задание в Redis)
- Если нет → пишет сразу в БД (TbOfferHistory записывается автоматически)
---
## МЕТАДАННЫЕ ОТСЛЕЖИВАНИЯ ПРОГРЕССА
В каждой **TbSource** должно быть поле `j_source_metadata`:
```python
j_source_metadata = JSONField(default=dict, help_text='Отслеживание парсинга и синонимы')
# Структура:
{
"last_parsed_line": 4523, # Докуда добежал парсер (для resume)
"total_lines": 10000, # Всего строк в файле
"parsed_at": "2026-06-14T12:30:00",# Время последнего парсинга
"status": "in_progress", # in_progress | completed | failed
"error_message": null, # если failed, чтобы видно было почему
# Найденные синонимы (стили, артисты, которые auto-matched)
"matched_styles": {
"Rock": 45, # Стиль 45 найден под названием "Rock"
"rock": 45, # Вариант написания também сохранили
},
"matched_artists": {
"The Beatles": 12,
"Beatles, The": 12,
}
}
```
Если парсер упал на строке 4523 → перезапуск продолжится с 4524, а не с начала!
---
## МАСШТАБИРУЕМОСТЬ: REDIS В ПАМЯТИ vs PERSISTENCE
### Проблема: 10k+ задач при загрузке большого Excel
```
Excel с 10,000 позиций
├─ 10k артистов
├─ 5k стилей
├─ 2k лейблов
└─ 15k общих задач в очереди
```
Redis хранит в памяти по умолчанию ➜ контейнер перестартует ➜ всё теряется!
**Решение: RDB + AOF Persistence**
```yaml
# docker-compose.yml для Redis
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
command: >
redis-server
--appendonly yes
--appendfsync everysec
--save 900 1
--maxmemory 2gb
--maxmemory-policy allkeys-lru
```
- **RDB snapshots** — снимок каждые 15 минут
- **AOF log** — каждая команда лог записывается на диск
- **maxmemory-policy** — если память 2GB переполнится, удаляются старые задачи (но только pending, не approved!)
- **Persistence**: при рестарте контейнера Redis восстановит все задачи из AOF
---
## ПАРАЛЛЕЛЬНАЯ ОБРАБОТКА (масштабирование)
Если задач много → **несколько воркеров**, каждый обрабатывает свой тип:
```python
# Worker 1: Парсит (пишет в tasks:pending)
celery_app.send_task('parser.parse_source', args=[source_id])
# Worker 2: Обрабатывает стили (слушает tasks:approved тип='style')
@app.task
def process_style_task(task_data):
# обновить БД
# Worker 3: Обрабатывает артистов (слушает tasks:approved тип='artist')
@app.task
def process_artist_task(task_data):
# обновить БД
# Worker 4: Обрабатывает цены (слушает tasks:price_changes)
@app.task
def update_offer_price(task_data):
# обновить цену
```
Каждый воркер работает в отдельном потоке/процессе ➜ параллелизм!
---
## FLOW ПРИНЦИПИАЛЬНАЯ СХЕМА (обновлённая)
```
┌──────────────────────────────────┐
│ ПАРСЕР (Worker 1) │
│ Читает Excel/CSV/JSON │
└──────────────┬───────────────────┘
┌──────────────┴────────────┐
│ │
┌───────▼───────────┐ ┌─────────▼──────────┐
│ Матч сработал? │ │ Новые данные? │
│ (aliasing) │ │ (неизвестны) │
└────┬──────────────┘ └─────────┬──────────┘
│ Yes │ No
│ │
┌────────▼────────────┐ ┌────────▼────────────┐
│ СРАЗУ В БД │ │ tasks:pending │
│ + j_style_synonyms │ │ (очередь нужнофала) │
│ + metadata['matched'] │ (ждут одобрения) │
└────────┬────────────┘ └────────┬────────────┘
│ │
│ АДМИНКА ДЖАНГО
│ (виртуальная таблица)
│ │
│ ┌──────┴──────┐
│ │ ✅ ❌ 🔗 │
│ (Approve/Skip/Merge)
│ │ │
│ ┌───────▼─────┐ │
│ │ tasks: │ │
│ │ approved │ │
│ └───────┬─────┘ │
│ │ │
└──────────┬─────────┴─────────────┘
Worker 2,3,4...
┌──────▼──────┐
│ PRODUCT БД │
│ (валидные) │
└─────────────┘
```
---
## ДОПОЛНИТЕЛЬНЫЕ МЕТАДАННЫЕ ДЛЯ КАЖДОЙ ЗАДАЧИ
```python
# Каждая задача в Redis содержит:
task = {
'id': 'pending:style:123',
'type': 'style', # или 'artist', 'label', 'price_change'
'data': {'title': 'Rock', ...},
# Откуда пришла
'source_id': 42,
'source_name': 'Discogs API',
'parsed_line': 4523,
# Когда создана
'created_at': '2026-06-14T12:00:00',
'ttl_seconds': 86400, # жить 24 часа, потом удалить
# Статус обработки
'attempts': 0, # сколько раз пытались обработать
'last_error': null,
}
```
---
## ИТОГОВАЯ АРХИТЕКТУРА (расширенная)
```
ИСТОЧНИКИ (Excel/API)
↓ (15k записей)
┌──────────────────────────────────────────────────┐
│ REDIS ОЧЕРЕДИ (в памяти + AOF persistence) │
│ ├─ tasks:pending (очередь неодобренных) │
│ ├─ tasks:approved (одобренные) │
│ ├─ tasks:price_changes (изменения цен) │
│ ├─ tasks:matched (уже в БД + синонимы) │
│ ├─ tasks:completed (завершённые) │
│ └─ tasks:failed (ошибки) │
└──────────────────────────────────────────────────┘
↓ (видит админка) ↓ (видят воркеры)
┌──────────────────┐ ┌────────────────────┐
│ АДМИНКА ДЖАНГО │ │ WORKERS (Celery) │
│ (виртуальная │ │ ├─ parse_source │
│ таблица) │ │ ├─ process_style │
│ Действия: │ │ ├─ process_artist │
│ ok/del/edit/... │ │ └─ update_prices │
└──────────────────┘ └────────────────────┘
↓ ↓
└──────────┬─────────────┘
┌─────────────────────┐
│ PRODUCT БД │
│ (только валидные) │
│ + metadata progress │
│ + aliases │
└─────────────────────┘
```
+215
View File
@@ -0,0 +1,215 @@
# Спецификация: LPON Store Hub DSL (Компактный конструктор интерфейсов на кортежах)
драфт
## 1. Обзор и цели
Мы реализуем гибкий, управляемый данными (Data-Driven) конструктор страниц внутри бэкенда Django для **LPON.RU**.
Вместо написания отдельных вьюх (Views) и шаблонов под каждый формат или раздел (например, `/catalog/cassettes/`, `/catalog/vinyl/`), мы формируем динамические **Страницы-Хабы (Hub Pages)** с помощью компактного JSON Tuple DSL, который хранится прямо в базе данных.
Система завязана на модель `TbArticle`. Конфигурация хранится в JSON-поле метаданных статьи (`j_article_metadata['hub']`). Механизм HUB может использоваться как для чистых хаб-страниц (`l_article_type = 'HUB'`), так и в качестве опционального блока **в любых других статьях/сущностях** для подключения динамических витрин и модулей.
### Генерализация списков страниц (Отказ от списочных View/Templates)
Все списочные страницы (например, список текстовых материалов `/info/`, список новостей `/blog/`, каталоги форматов) рассматриваются как **частный случай хаба**:
1. Вместо написания отдельной вьюхи (например, `txt_articles_list`) в БД создаётся статья со слагом `info` и типом `HUB`.
2. В её DSL-конфигурации прописываются требуемые блоки (например, `[["info-articles", "list", 50]]`).
3. Рендеринг выполняют не отдельные функции-представления, а единый универсальный диспетчер хабов (`HubView`).
4. **Каноничность URL элементов:** Внутри шаблонов визуальных блоков ссылки на карточки и статьи выводится строго через `href="{{ item.get_absolute_url }}"`. Модель статьи/товара сама определяет свой канонический адрес по типу (например, `/info/privacy-policy` или `/item/album-slug`), исключая путаницу префиксов в ссылках независимо от того, в каком хабе опубликован блок.
---
## 2. Синтаксис и структура JSON DSL
Массив `hub` представляет собой список позиционных кортежей (массивов). Порядок элементов в кортеже строго фиксирован. Все элементы, кроме первого (`slug` / `source`), являются опциональными и имеют безопасные значения по умолчанию.
Позиционный порядок параметров: `[ "slug", "view_mode", limit, "sort", extra ]`
### Описание позиций кортежа:
1. **`slug` / `source` (str, 1-я позиция, обязательный)**:
- Слаг точечной статьи/категории (например, `"cassettes-studio"`).
- Динамическая выборка с двоеточием (например, `"offers:artist:the-beatles"`, `"articles:type:info"`).
- Модуль/плагин с префиксом `!` (например, `"!promo-mandarin"`).
2. **`view_mode` (str, 2-я позиция, опциональный, default зависит от `ArticleType` или `"grid"`)**:
- Название компонента рендеринга (шаблона): `carousel`, `grid`, `list`, `teaser`, `full`, `slider`.
- Поддерживает составной синтаксис через `+` (например, `"teaser+list"`).
3. **`limit` (int | str, 3-я позиция, опциональный, default=`10`)**:
- Количество выбираемых элементов (например, `10`, `50`).
- Для составных шаблонов задаётся через `+` (например, `"2+15"`).
4. **`sort` (str, 4-я позиция, опциональный, default=`"new+"`)**:
- Правило сортировки элементов выборки (см. правила ниже: `new+`, `price+`, `price-`, `random` и т.д.).
5. **`extra` (dict, 5-я позиция, опциональный, default=`{}`)**:
- Произвольный словарь с дополнительными настройками блока (например, `{"title": "Кастомный заголовок"}`).
### Правила синтаксиса:
```json
{
"hub": [
["source-slug", "view_mode"],
["source-slug", "view_mode", limit],
["source-slug", "view_mode", limit, "sort_rule"],
["source-slug", "view_mode", limit, "sort_rule", {"title": "Кастомный заголовок"}],
["articles:type:info", "teaser+list", "2+15", "new+"],
["!module-slug"]
]
}
```
---
## 3. Спецификация блоков и Мета-язык
### Специальные символы синтаксиса DSL
| Символ | Назначение | Пример | Описание |
|:--------|:-------------------------------------|:--------------------------------------------------|:-----------------------------------------------------------|
| **`!`** | Модуль / Плагин | `"!promo-mandarin"`, `"!cart-bonus"` | Запуск зарегистрированного Python-модуля |
| **`:`** | Динамическая выборка (Namespace) | `"articles:type:info"`, `"offers:artist:beatles"` | Выборка из БД (офферы или статьи) по критерию |
| **`+`** | Составной режим отображения и лимита | `"teaser+list"`, `"2+15"` | Один SQL-запрос с разбиением результатов на разные шаблоны |
---
### Правила сортировки (`sort`)
Для параметра `sort` (4-я позиция) используются короткие наглядные обозначения направления сортировки (`+` — по убыванию/свежие/популярные, `-` — по возрастанию/старые/дешевые):
| Значение `sort` | Смысл сортировки | Эквивалент поля в БД |
| :--- | :--- | :--- |
| **`new+`** *(default)* | Сначала новые (новинки) | `-t_created` / `-t_article_created` |
| **`new-`** | Сначала старые | `t_created` / `t_article_created` |
| **`price+`** | Сначала дешевые | `f_offer_price` (по возрастанию) |
| **`price-`** | Сначала дорогие | `-f_offer_price` (по убыванию) |
| **`view+`** | Самые просматриваемые (хиты) | `-i_views` / `-i_article_views` |
| **`view-`** | Наименее просматриваемые | `i_views` / `i_article_views` |
| **`favorite+`** | Часто в избранном | `-i_favorites` / `-i_article_favorites` |
| **`favorite-`** | Редко в избранном | `i_favorites` / `i_article_favorites` |
| **`random`** | Случайная выборка | `?` (`order_by('?')`) |
*Примечание:* Для обратной совместимости и отказоустойчивости парсер бэкенда также принимает устаревшие текстовые значения (`newest` $\rightarrow$ `new+`, `popular` $\rightarrow$ `view+`, `price_asc` $\rightarrow$ `price+`, `price_desc` $\rightarrow$ `price-`).
---
### Тип A: Конкретная точечная сущность (Обычный slug)
Загружает конкретную статью, категорию или жанр по их собственному первичному слагу в БД.
Формат: `[ "slug", "view_mode", limit, "sort", extra ]`
Примеры:
- `["cassettes-studio"]` — конкретная категория/статья (дефолтные view_mode по `ArticleType`, limit=10, sort="new+")
- `["guide-how-to-clean-heads", "teaser"]` — тизер конкретной статьи
---
### Тип B: Динамическая выборка / Запрос к БД (Префикс с двоеточием `:`)
Позволяет формировать динамические списки офферов (`offers:...`) или статей (`articles:...`) по заданным критериям без создания отдельных вьюх.
Формат источника (`source`):
- `offers:artist:<slug>` — офферы конкретного исполнителя (например, `offers:artist:the-beatles`)
- `offers:format:<slug>` — офферы конкретного формата (например, `offers:format:vinyl`)
- `offers:style:<slug>` — офферы музыкального стиля (например, `offers:style:jazz`)
- `articles:type:<type>` — статьи определенного типа `ArticleType` (например, `articles:type:info`, `articles:type:blog`)
- `articles:artist:<slug>` — статьи, связанные с исполнителем (например, `articles:artist:the-beatles`)
Примеры:
- `["articles:type:info", "list", 50]` — до 50 статей типа `INFO` в виде списка
- `["offers:artist:the-beatles", "carousel", 10, "view+"]` — 10 популярных релизов The Beatles в карусели
---
### Составной режим отображения (`view_mode` + `limit` через `+`)
Позволяет вывести результаты одного SQL-запроса через комбинацию нескольких шаблонов (например, первые 2 элемента крупными тизерами, а остальные 15 элементов — компактным списком снизу) без дублирования блоков.
Синтаксис: `"teaser+list"`, `"2+15"`
Пример:
- `["articles:type:info", "teaser+list", "2+15", "new+", {"title": "Инструкции и справка"}]`
- Выполняется 1 SQL-запрос с `LIMIT = 2 + 15 = 17`.
- Первые 2 статьи передаются в шаблон `teaser`.
- Следующие 15 статей передаются в шаблон `list`.
---
### Тип C: Динамический модуль / Плагин (Префикс `!`)
Запускает кастомный Python-модуль, зарегистрированный в системе (например, интерактивные виджеты, спецпредложения корзины, акции).
Формат: `[ "!module-slug", extra ]`
Отличительный признак: `slug` начинается с символа **`!`** (восклицательный знак). Так как знак `!` недопустим в обычных URL-слагах Django, парсер однозначно и безошибочно определяет, что данный блок является вызовом Python-плагина.
Пример:
- `["!promo-mandarin"]`
- `["!cart-bonus", {"threshold": 5000}]`
---
## 4. Полный пример конфига (j_article_metadata)
```json
{
"hub": [
["hero-cassettes-2026", "slider"],
["articles:type:info", "teaser+list", "2+15", "new+", {"title": "Инструкции и справочные материалы"}],
["offers:artist:the-beatles", "carousel", 10, "view+", {"title": "Релизы The Beatles в наличии"}],
["cassettes-studio", "carousel", 6, "view+"],
["!promo-mandarin"],
["offers:format:vinyl", "grid", 20, "price+"],
["!cart-bonus", {"threshold": 5000}]
]
}
```
---
## 5. Защита и логика бэкенда (Требования к Python / Django)
При разработке парсера DSL:
1. Защита от зацикливания и бесконечной рекурсии:
- Передавать множество `visited_slugs` при рендере страницы.
- Если вложенный блок пытается загрузить `slug`, который уже есть в `visited_slugs`, пропускать блок или выводить отладочный HTML-комментарий (`<!-- DSL Error: Circular reference detected for slug -->`).
2. Ограничение глубины вложенности:
- Жестко ограничить максимальную глубину рекурсии: `MAX_RECURSION_DEPTH = 2` (устанавливается в `settings.py`).
3. Отказоустойчивость (Graceful Degradation):
- Если `limit` передан не числом или `sort` содержит неизвестную строку, парсер должен тихо применить безопасные дефолты (автовыбор `view_mode` по `ArticleType` с фолбэком на `'grid'`, `limit=10`, `sort='new+'`). Ошибка синтаксиса в JSON никогда не должна приводить к `500 Server Error`.
- Если `slug` (без `!`) не найден в базе, блок пропускается, а в HTML выводится комментарий: `<!-- DSL Error: Slug not found -->`.
- Если модуль `!slug` не зарегистрирован в PluginRegistry, выводится комментарий: `<!-- DSL Error: Module not found -->`.
4. Изоляция данных:
- Внутри DSL запрещено использовать сырые имена полей БД (например, k_offer_to_item__s_item). Все выборки данных маппятся строго через `slug` и внутренние обработчики запросов (Query Handlers).
5. Валидация slug
- Добавить в валидатор админки статей (TbArticle) проверку на несовпадение slug с существующими роутами сайта из `urls.py` (чтобы не было конфликта URL-ов).
---
## 6. Архитектура исполнения DSL и Реестр обработчиков (DSL Query Registry)
Для предотвращения написания «макаронного» кода во вьюхах и обеспечения высокой расширяемости система исполнения DSL строится на основе паттерна **«Реестр обработчиков» (Registry Pattern)**.
### Принцип разделения ответственности:
1. **Вьюха `hub_detail` (`HubView`):**
- Выступает исключительно в роли входных ворот.
- Ищет объект `TbArticle` по слагу и передаёт его в контекст шаблона `content/hub.html`.
- Не содержит хардкода выборок, разбора параметров и формирования SQL-запросов.
2. **Template Tag `{% render_hub_blocks article %}`:**
- Читает массив `j_article_metadata['hub']` из полученной статьи.
- Итерируется по кортежам DSL и передаёт их в ядро исполнения DSL.
- Вызывает шаблоны визуальных компонентов (`carousel.html`, `grid.html`, `list.html`, `teaser.html`) с контекстом, подготовленным обработчиками.
3. **Реестр обработчиков выборок (`DSLQueryRegistry`):**
- Модуль, содержащий карту функций-обработчиков (Query Handlers), зарегистрированных через декоратор `@DSLQueryRegistry.register("prefix")`.
- Каждое пространство имён 1-го позиционного параметра (`articles:type`, `offers:artist`, `offers:format`, `offers:style`, `single_slug`, `!module`) имеет свой изолированный обработчик на 3–5 строк кода.
### Принцип обработки автоматических пресетов для товаров (`ArticleType.ITEM`):
- Если для конкретного релиза/товара `j_article_metadata['hub']` пуст, ядро DSL формирует динамический виртуальный массив блоков на основе Foreign Key связей объекта (`k_item_to_artist`, `k_offer_to_label`, `k_style`).
- Виртуальные блоки прогоняются через тот же самый реестр `DSLQueryRegistry`, обеспечивая единообразие вывода контента без загромождения БД и дублирования логики.
film0069t-2001.08.xx
+748
View File
@@ -0,0 +1,748 @@
# Организация справочника музыкальных стилей
# Состояние списка музыкальных стилей в Discogs (июль 2026)
_AOR, Aboriginal, Abstract, Acid, Acid House, Acid Jazz, Acid Rock, Acoustic, African, Afro-Cuban, Afro-Cuban Jazz,
Afrobeat, Afrobeat, Aguinaldo, Aleatoric, Alt-Pop, Alternative Metal, Amapiano, Ambient, Ambient House, Anarcho-Punk,
Anatolian Rock, Andalusian Classical, Andean Music, Anison, Antifolk, Appalachian Music, Arabic Pop, Arena Rock,
Art Rock, Art Song, Atmospheric Black, Metal, Atonal, Audiobook, Avant-garde Jazz, Axé, Azonto, Bachata, Baila,
Baião, Bakersfield Sound, Balearic, Ballad, Ballet, Ballet, Ballroom, Baltimore Club, Bambuco, Banda, Bangladeshi,
Classical, Barbershop, Baroque, Baroque Pop, Basque Music, Bass Music, Bass Music, Bassline, Batucada, Bayou Funk,
Beat, Beatbox, Beatdown, Beguine, Bel Canto, Bengali Music, Berlin-School, Bećarac, Bhangra, Big Band, Big Beat,
Bitpop, Black Metal, Blackgaze, Bleep, Bluegrass, Blues Rock, Bolero, Bollywood, Bomba, Bongo Flava, Boogaloo, Boogie,
Boogie Woogie, Boom Bap, Bop, Bossa Nova, Bossanova, Bounce, Bouncy Techno, Brass Band, Brass Instrument, Break-In,
Breakbeat, Breakcore, Breaks, Brega, Britcore, Britpop, Broken Beat, Brutal Death Metal, Bubblegum, Bubbling,
Bultrón, Byzantine, Cabaret, Caipira, Cajun, Calypso, Cambodian Classical, Candombe, Cantata, Cantopop, Cantorial,
Canzone Napoletana, Cape Jazz, Capriccio, Carimbó, Carnatic, Catalan Music, Celtic, Cha-Cha, Chacarera, Chamamé,
Chamber Music, Champeta, Chanson, Charanga, Chicago Blues, Chillwave, Chinese Classical, Chiptune, Choral, Choro,
Chutney, City Pop, Classic Rock, Classical, Classical Guitar, Cloud Rap, Cobla, Coldwave, Comedy, Comfy Synth, Compas,
Concert Band, Concert Film, Concerto, Concerto Grosso, Conjunto, Conscious, Contemporary, Contemporary Jazz,
Contemporary R&amp;B, Cool Jazz, Copla, Corrido, Country, Country Blues, Country Rock, Cretan, Crossover Thrash,
Crunk, Crust, Cuatro, Cubano, Cumbia, Currulao, Cut-up/DJ, DJ Battle Tool, Dabke, Dance-pop, Dancehall, Dances,
Dangdut, Dansband, Danzon, Dark Ambient, Dark Electro, Dark Jazz, Darkwave, Death Metal, Deathcore, Deathrock,
Deconstructed Club, Deep House, Deep Techno, Delta Blues, Depressive Black, Metal, Descarga, Desert Blues, Dialogue,
Disco, Disco, Disco Polo, Divertimento, Dixieland, Donk, Doo Wop, Doo Wop, Doom Metal, Doomcore, Downtempo, Dream Pop,
Drill, Drone, Drum n Bass, Dub, Dub, Dub Poetry, Dub Techno, Dubstep, Dungeon Synth, Duranguense, EBM, Early,
East Coast Blues, Easy Listening, Education, Educational, Electric Blues, Electro, Electro, Electro House,
Electro Swing, Electroacoustic, Electroclash, Emo, Enka, Erotic, Ethereal, Ethno-pop, Etude, Euro House, Euro Trance,
Euro-Disco, Eurobeat, Eurodance, Europop, Exotica, Expressionist, Fado, Fantasia, Favela Funk, Field Recording, Filk,
Flamenco, Folk, Folk Metal, Folk Rock, Footwork, Formes Fixes, Forró, Free Funk, Free Improvisation, Free Jazz,
Freestyle, Freetekno, French House, Frevo, Funaná, Funeral Doom Metal, Funk, Funk Metal, Funkot, Fusion,
Future Bass, Future House, Future Jazz, Future Pop, Futurism, G-Funk, Gabber, Gagaku, Gaita, Galician Traditional,
Gamelan, Gangsta, Garage House, Garage Rock, Geet, Ghazal, Ghetto, Ghetto House, Ghettotech, Glam, Glitch, Glitch Hop,
Gnawa, Go-Go, Go-Go, Goa Trance, Goregrind, Gospel, Gospel, Goth Rock, Gothic Metal, Gqom, Grand Opera, Grime, Grime,
Grindcore, Griot, Groove Metal, Group Sounds, Grunge, Guaguancó, Guajira, Guaracha, Guarania, Guggenmusik, Gusle,
Gwo Ka, Gypsy Jazz, Għana, Halftime, Hands Up, Happy Hardcore, Hard Beat, Hard Bop, Hard House, Hard Rock,
Hard Techno, Hard Trance, Hardcore, Hardcore, Hardcore Hip-Hop, Hardstyle, Harmonica Blues, Harpsichord,
Harsh Noise Wall, Hawaiian, Health-Fitness, Heavy Metal, Hi NRG, Highlife, Hill Country Blues, Hillbilly, Hindustani,
Hip Hop, Hip-House, Hiplife, Hokkien Pop, Holiday, Honky Tonk, Honkyoku, Horror Rock, Horrorcore, House, Huayno,
Hyper Techno, Hyperpop, Hyphy, Hypnagogic pop, IDM, Illbient, Impressionist, Incidental Music, Indian Classical,
Indie Pop, Indie Rock, Indo-Pop, Industrial, Industrial, Industrial Metal, Instrumental, Interview, Italo House,
Italo-Disco, Italodance, Izvorna, J-Core, J-Rock, J-pop, Jangle Pop, Jazz-Funk, Jazz-Rock, Jazzdance, Jazzy Hip-Hop,
Jersey Club, Jibaro, Jiuta, Joropo, Jota, Jug Band, Juke, Jump Blues, Jumpstyle, Jungle, Junkanoo, K-Rock, K-pop,
Karaoke, Kaseko, Kaskawi, Kayōkyoku, Keroncong, Keyboard, Khaliji, Kizomba, Klasik, Klezmer, Kolo, Korean Court Music,
Krautrock, Kuduro, Kwaito, Lambada, Lao Music, Late Romantic, Latin, Latin Jazz, Latin Pop, Laïkó, Leftfield,
Lento Violento, Levenslied, Lied, Light Music, Liscio, Lo-Fi, Louisiana Blues, Lounge, Lovers Rock, Low Bap,
Lowercase, Luk Krung, Luk Thung, Lullaby, MPB, Madrigal, Magnificat, Makina, Maloya, Mambo, Mandopop, Manila Sound,
Marcha Carnavalesca, Marches, Mariachi, Marimba, Mass, Math Rock, Mathcore, Mbalax, Medical, Medieval,
Melodic Death Metal, Melodic Hardcore, Memphis Blues, Memphis Rap, Mento, Merengue, Metalcore, Miami Bass, Microhouse,
Microtonal, Midwest Emo, Military, Milonga, Min'yō, Minimal, Minimal Techno, Minimalism, Minneapolis Sound, Mizrahi,
Mo Lam, Mod, Modal, Modern, Modern Classical, Modern Electric, Blues, Monolog, Moombahton, Morna, Motet, Motswako,
Mouth Music, Movie Effects, Mugham, Musette, Music Hall, Music Video, Musical, Musique Concrète, Música Criolla, NDW,
Nagauta, Neo Soul, Neo Trance, Neo-Classical, Neo-Classical Metal, Neo-Romantic, Neofolk, Neofolk, Neopagan,
Nerdcore Techno, New Age, New Beat, New Jack Swing, New Wave, New Wave, Nhạc Vàng, Nintendocore, No Wave, Nocturne,
Noise, Noise, Noise Rock, Noisecore, Nordic, Norteño, Novelty, Nu Metal, Nu-Disco, Nueva Cancion, Nueva Trova,
Nursery Rhymes, Néo Kyma, Népzene, Occitan, Occult, Oi, Ojkača, Opera, Operetta, Oratorio, Orchestra, Organ,
Ottoman Classical, Overtone Singing, Overture, P.Funk, Pachanga, Pacific, Pagode, Parody, Parody, Pasodoble, Passion,
Percussion Ensemble, Persian Classical, Persian Pop, Philippine Classical, Phleng Phuea Chiwit, Phonk, Piano,
Piano Blues, Piedmont Blues, Piobaireachd, Pipe &amp; Drum, Plainchant, Plena, Plunderphonics, Poetry, Political,
Polka, Pop Punk, Pop Rap, Pop Rock, Pornogrind, Porro, Post Bop, Post Rock, Post-Grunge, Post-Hardcore, Post-Metal,
Post-Modern, Post-Punk, Power Electronics, Power Metal, Power Pop, Power Violence, Prelude, Prog Rock, Program Music,
Progressive, Bluegrass, Progressive Breaks, Progressive House, Progressive Metal, Progressive Trance, Promotional,
Psy-Trance, Psychedelic, Psychedelic Rock, Psychobilly, Pub Rock, Public Broadcast, Public, Service Announcement,
Punk, Qawwali, Quechua, Radioplay, Ragga, Ragga HipHop, Ragtime, Ranchera, Rapso, Rara, Raï, Rebetiko, Reggae,
Reggae Gospel, Reggae-Pop, Reggaeton, Religious, Renaissance, Requiem, Rhapsody, Rhythm &amp; Blues, Rhythm & Blues,
Rhythmic Noise, RnB/Swing, Rock &amp; Roll, Rock Opera, Rockabilly, Rocksteady, Romani, Romantic, Roots Reggae, Rumba,
Rune Singing, Russian Pop, Ryūkōka, Rōkyoku, Salegy, Salsa, Samba, Samba-Canção, Sankyoku, Schlager, Schranz, Score,
Screamo, Screw, Sea Shanties, Sean-nós, Sephardic, Serenade, Seresta, Serial, Sermon, Sertanejo, Shaabi, Shibuya-Kei,
Shidaiqu, Shinkyoku, Shoegaze, Shomyo, Singeli, Singspiel, Ska, Ska, Skiffle, Skweee, Slowcore, Sludge Metal,
Smooth Jazz, Snap, Soca, Soft Rock, Sokyoku, Solo, Son, Son Montuno, Sonata, Sonero, Soukous, Soul, Soul-Jazz,
Sound Art, Sound Collage, Sound Poetry, Soundtrack, Southern Rock, Space Rock, Space-Age, Spaza, Special Effects,
Spectralism, Speech, Speed Garage, Speed Metal, Speedcore, Spiritual Jazz, Spirituals, Spoken Word, Sport, Sport,
Steel Band, Stoner Rock, Story, Stride, String Ensemble, String Instrument, Style Galant, Suite, Sunshine Pop,
Suomisaundi, Surf, Swamp Pop, Swing, Swingbeat, Symphonic Metal, Symphonic Rock, Symphony, Synth-pop, Synthpunk,
Synthwave, Sámi Music, Séga, Taarab, Tamburitza, Tamil Film Music, Tango, Te Deum, Tech House, Tech Trance,
Technical, Technical Death, Metal, Techno, Tejano, Texas Blues, Thai Classical, Theme, Theme With, Variations,
Therapy, Thrash, Thug Rap, Timba, Toasting, Tone Poem, Trallalero, Trance, Trap, Tribal, Tribal House, Trip Hop,
Trip Hop, Tropical House, Trova, Turntablism, Twelve-tone, Twist, UK Funky, UK Garage, UK Street Soul, Unblack Metal,
V-pop, Vallenato, Vaporwave, Vaudeville, Verismo, Video Game Music, Viking Metal, Villancicos, Vocal, Vocaloid,
Volksmusik, Waiata, Western Swing, Wind Ensemble, Witch House, Woodwind Instrument, Yemenite Jewish, Yoruba, Yé-Yé,
Zamba, Zarzuela, Zemer Ivri, Zhongguo Feng, Zouk, Zydeco, Éntekhno_
Всего: ~810
### Схема группировки для LPON для модели TbMusicStyle в синонимы
**Статус:** ✅ Загружено 22 основных стиля с 729 синонимами
| № | Категория | Синонимов | Примеры |
|-----|--------------------------------------------|-----------|-----------------------------------------------------------|
| 1 | Classic Rock | 14 | AOR, Acid Rock, Arena Rock, Hard Rock, Heavy Metal... |
| 2 | Alternative & Indie Rock | 19 | Alt-Pop, Grunge, Shoegaze, Indie Rock, Dream Pop... |
| 3 | Jazz | 21 | Big Band, Cool Jazz, Fusion, Bop, Dixieland... |
| 4 | Blues | 17 | Chicago Blues, Delta Blues, Jump Blues, Electric Blues... |
| 5 | Heavy Metal & Extreme Metal | 32 | Death Metal, Black Metal, Grindcore, Metalcore... |
| 6 | Punk & Hardcore | 19 | Punk, Post-Hardcore, Emo, Screamo, Crust.. |
| 7 | R&B & Hip Hop | 28 | Rap, Drill, Trap, Boom Bap, Memphis Rap... |
| 8 | Reggae & Dub | 14 | Dancehall, Roots Reggae, Ska, Rocksteady... |
| 9 | Folk, Country & Americana | 17 | Country, Bluegrass, Folk Rock, Honky Tonk... |
| 10 | Prog & Art Rock | 10 | Progressive Rock, Krautrock, Psychedelic... |
| 11 | Pop Music | 19 | Pop Rock, Bubblegum, Chanson, J-pop, K-pop... |
| 12 | Disco, Funk & Soul | 29 | Funk, Soul, Boogie, Hi NRG, Neo Soul... |
| 13 | Techno & House | 43 | House, Techno, Deep House, Minimal, Hardstyle... |
| 14 | Trance & Hardcore Electronic | 8 | Trance, Goa Trance, Psy-Trance, Hardcore... |
| 15 | Ambient, IDM & Down-tempo | 23 | Ambient, IDM, Drone, Trip Hop, Vaporwave... |
| 16 | Bass, Breakbeat & Drum n Bass | 35 | Drum n Bass, Jungle, Breakbeat, Dubstep... |
| 17 | Industrial, Noise & Avant-Garde Electronic | 11 | Industrial, Noise, EBM, Musique Concrète... |
| 18 | New Wave & Synth-Pop | 13 | Synth-pop, New Wave, Coldwave, Synthwave... |
| 19 | Classical & Academic | 72 | Classical, Baroque, Opera, Symphony, Chamber... |
| 20 | World, Ethnic & Traditional | 240 | Salsa, Tango, Flamenco, Bhangra, Gnawa... |
| 21 | Stage, Screen & Soundtracks | 13 | Soundtrack, Musical, Theme, Video Game Music... |
| 22 | Non-Music / Spoken Word / Miscellaneous | 32 | Audiobook, Spoken Word, Comedy, Interview... |
### Полный справочник синонимов
#### 1. Classic Rock (14 синонимов)
```
• AOR (Album Oriented Rock)
• Acid Rock
• Arena Rock
• Beat
• Hard Rock
• Heavy Metal
• Mod
• Pub Rock
• Rock & Roll
• Southern Rock
• Space Rock
• Surf
• Twist
```
#### 2. Alternative & Indie Rock (19 синонимов)
```
• Alt-Pop
• Alternative Rock
• Darkwave
• Dream Pop
• Goth Rock
• Grunge
• Hypnagogic Pop
• Indie Pop
• Indie Rock
• Jangle Pop
• Lo-Fi
• Math Rock
• Midwest Emo
• Post Rock
• Post-Grunge
• Shoegaze
• Slowcore
• Stoner Rock
```
#### 3. Jazz (21 синоним)
```
• Avant-garde Jazz
• Big Band
• Bop
• Cape Jazz
• Contemporary Jazz
• Cool Jazz
• Dark Jazz
• Dixieland
• Free Jazz
• Fusion
• Gypsy Jazz
• Hard Bop
• Jazz-Rock
• Jazzdance
• Modal
• Post Bop
• Smooth Jazz
• Spiritual Jazz
• Stride
• Swing
```
#### 4. Blues (17 синонимов)
```
• Blues Rock
• Chicago Blues
• Country Blues
• Delta Blues
• East Coast Blues
• Electric Blues
• Harmonica Blues
• Hill Country Blues
• Jug Band
• Jump Blues
• Louisiana Blues
• Memphis Blues
• Modern Electric Blues
• Piano Blues
• Piedmont Blues
• Texas Blues
```
#### 5. Heavy Metal & Extreme Metal (32 синонима)
```
• Alternative Metal
• Atmospheric Black Metal
• Black Metal
• Blackgaze
• Brutal Death Metal
• Crossover Thrash
• Death Metal
• Deathcore
• Depressive Black Metal
• Doom Metal
• Funeral Doom Metal
• Funk Metal
• Goregrind
• Gothic Metal
• Grindcore
• Groove Metal
• Industrial Metal
• Melodic Death Metal
• Melodic Hardcore
• Metalcore
• Neo-Classical Metal
• Nu Metal
• Post-Metal
• Power Metal
• Progressive Metal
• Sludge Metal
• Symphonic Metal
• Technical Death Metal
• Thrash
• Unblack Metal
• Viking Metal
```
#### 6. Punk & Hardcore (19 синонимов)
```
• Anarcho-Punk
• Antifolk
• Britpop
• Crust
• Emo
• Hardcore
• Oi
• Pop Punk
• Post-Hardcore
• Post Punk
• Power Pop
• Power Violence
• Psychobilly
• Punk
• Screamo
• Skiffle
```
#### 7. R&B & Hip Hop (28 синонимов)
```
• Amapiano
• Azonto
• Boom Bap
• Cloud Rap
• Conscious
• Contemporary R&B
• Crunk
• Drill
• Ghetto
• Hip Hop
• Hip-House
• Hiplife
• Horrorcore
• Hyperpop
• Hyphy
• Jazzy Hip-Hop
• Memphis Rap
• Motswako
• Nerdcore
• Phonk
• Pop Rap
• Ragga HipHop
• Rhythm & Blues
• Snap
• Spaza
• Thug Rap
• Trap
```
#### 8. Reggae & Dub (14 синонимов)
```
• Dancehall
• Dub
• Dub Poetry
• Lovers Rock
• Ragga
• Rapso
• Reggae Gospel
• Reggae-Pop
• Rocksteady
• Roots Reggae
• Ska
• Toasting
```
#### 9. Folk, Country & Americana (17 синонимов)
```
• Acoustic
• Appalachian Music
• Bakersfield Sound
• Bluegrass
• Country
• Country Rock
• Filk
• Folk
• Folk Metal
• Folk Rock
• Hillbilly
• Honky Tonk
• Neofolk
• Progressive Bluegrass
• Rockabilly
• Western Swing
```
#### 10. Prog & Art Rock (10 синонимов)
```
• Art Rock
• Berlin-School
• Group Sounds
• Krautrock
• Neo-Prog
• Prog Rock
• Psychedelic
• Psychedelic Rock
• Symphonic Rock
```
#### 11. Pop Music (19 синонимов)
```
• Ballad
• Baroque Pop
• Bubblegum
• Chanson
• Dance-pop
• Europop
• Highlife
• Holiday
• J-pop
• K-pop
• Kayōkyoku
• Mandopop
• Nursery Rhymes
• Pop Rock
• Russian Pop
• Sunshine Pop
• V-pop
• Vocal
• Yé-Yé
```
#### 12. Disco, Funk & Soul (29 синонимов)
```
• Acid Jazz
• Bayou Funk
• Boogie
• Boogie Woogie
• City Pop
• Disco
• Disco Polo
• Electro Swing
• Euro-Disco
• Free Funk
• Funk
• G-Funk
• Ghetto Tech
• Go-Go
• Hi NRG
• Italo-Disco
• Jazz-Funk
• Minneapolis Sound
• Motown
• Neo Soul
• New Jack Swing
• Nu-Disco
• P.Funk
• RnB/Swing
• Shibuya-Kei
• Soul
• Swingbeat
• UK Street Soul
```
#### 13. Techno & House (43 синонима)
```
• Acid House
• Ambient House
• Balearic
• Baltimore Club
• Bouncy Techno
• Deconstructed Club
• Deep House
• Deep Techno
• Donk
• Dub Techno
• Electro House
• Euro House
• Favela Funk
• French House
• Future House
• Garage House
• Ghetto House
• Hard House
• Hard Techno
• Hardstyle
• House
• Hyper Techno
• Italo House
• Italodance
• J-Core
• Jumpstyle
• Kuduro
• Lento Violento
• Makina
• Microhouse
• Minimal
• Minimal Techno
• Moombahton
• Nerdcore Techno
• New Beat
• Progressive House
• Schranz
• Speed Garage
• Tech House
• Techno
• Tribal House
• UK Funky
```
#### 14. Trance & Hardcore Electronic (8 синонимов)
```
• Euro Trance
• Goa Trance
• Hard Trance
• Neo Trance
• Progressive Trance
• Psy-Trance
• Trance
```
#### 15. Ambient, IDM & Down-tempo (23 синонима)
```
• Abstract
• Acid
• Ambient
• Chillwave
• Comfy Synth
• Dark Ambient
• Downtempo
• Drone
• Dungeon Synth
• Electro
• Glitch
• IDM
• Illbient
• Leftfield
• Lowercase
• New Age
• Plunderphonics
• Skweee
• Sound Art
• Trip Hop
• Vaporwave
• Witch House
```
#### 16. Bass, Breakbeat & Drum n Bass (35 синонимов)
```
• Bass Music
• Bassline
• Big Beat
• Bleep
• Bounce
• Breakbeat
• Breakcore
• Breaks
• Britcore
• Broken Beat
• Bubbling
• Club
• Cut-up/DJ
• DJ Battle Tool
• Drum n Bass
• Dubstep
• Footwork
• Freetekno
• Gabber
• Glitch Hop
• Grime
• Halftime
• Hands Up
• Happy Hardcore
• Hard Beat
• Jersey Club
• Juke
• Jungle
• Progressive Breaks
• Reggaeton
• Speedcore
• Supa
• Turntablism
• UK Garage
```
#### 17. Industrial, Noise & Avant-Garde Electronic (11 синонимов)
```
• EBM (Electronic Body Music)
• Electroacoustic
• Harsh Noise Wall
• Industrial
• Musique Concrète
• Noise
• Noise Rock
• Noisecore
• Power Electronics
• Rhythmic Noise
```
#### 18. New Wave & Synth-Pop (13 синонимов)
```
• Coldwave
• Electroclash
• Eurobeat
• Eurodance
• Future Pop
• NDW
• New Wave
• No Wave
• Néo Kyma
• Synth-pop
• Synthpunk
• Synthwave
```
#### 19. Classical & Academic (72 синонима)
```
• Andalusian Classical
• Baroque
• Bel Canto
• Byzantine
• Cantata
• Capriccio
• Chamber Music
• Choral
• Classical
• Classical Guitar
• Concert Band
• Concerto
• Concerto Grosso
• Contemporary
• Divertimento
• Early
• Expressionist
• Etude
• Fantasia
• Formes Fixes
• Futurism
• Harpsichord
• Impressionist
• Incidental Music
• Instrumental
• Keyboard
• Late Romantic
• Lied
• Light Music
• Madrigal
• Magnificat
• Mass
• Medieval
• Modern Classical
• Modern
• Motet
• Nocturne
• Opera
• Operetta
• Oratorio
• Orchestra
• Organ
• Overture
• Passion
• Percussion Ensemble
• Piano
• Pipe & Drum
• Plainchant
• Prelude
• Program Music
• Renaissance
• Requiem
• Rhapsody
• Romantic
• Serenade
• Serial
• Singspiel
• Solo
• Sonata
• Spectralism
• String Ensemble
• String Instrument
• Style Galant
• Suite
• Symphony
• Te Deum
• Theme With Variations
• Twelve-tone
• Verismo
• Wind Ensemble
• Woodwind Instrument
```
#### 20. World, Ethnic & Traditional (240 синонимов)
```
• Aboriginal, African, Afro-Cuban, Afrobeat
• Aguinaldo, Anatolian Rock, Andean Music, Arabic Pop
• Axé, Bachata, Baila, Baião, Ballet, Ballroom
• Bambuco, Banda, Bangladeshi Classical, Barbershop
• Basque Music, Batucada, Beguine, Bengali Music
• Bećarac, Bhangra, Bolero, Bollywood, Bomba
• Bongo Flava, Boogaloo, Bossa Nova, Bossanova
• Brass Band, Brass Instrument, Brega, Bultrón
• Caipira, Cajun, Calypso, Cambodian Classical
• Candombe, Cantopop, Cantorial, Canzone Napoletana
• Carimbó, Carnatic, Catalan Music, Celtic
• Cha-Cha, Chacarera, Chamamé, Champeta, Charanga
• Chinese Classical, Choro, Chutney, Cobla
• Compas, Conjunto, Copla, Corrido, Cretan
• Cuatro, Cubano, Cumbia, Currulao, Dabke
• Dangdut, Dansband, Danzon, Descarga, Desert Blues
• Duranguense, Ethno-pop, Exotica, Fado, Flamenco
• Forró, Freestyle, Frevo, Funaná, Funkot
• Gagaku, Gaita, Galician Traditional, Gamelan
• Geet, Ghazal, Gnawa, Gospel, Gqom, Griot
• Guaguancó, Guajira, Guaracha, Guarania, Guggenmusik
• Gusle, Gwo Ka, Għana, Hawaiian, Hindustani
• Hokkien Pop, Huayno, Indian Classical, Indo-Pop
• Izvorna, Jibaro, Jiuta, Joropo, Jota
• Junkanoo, Kaseko, Kaskawi, Keroncong, Khaliji
• Kizomba, Klasik, Klezmer, Kolo, Korean Court Music
• Kwaito, Lambada, Lao Music, Latin, Latin Jazz
• Latin Pop, Laïkó, Levenslied, Liscio, Lounge
• Luk Krung, Luk Thung, Lullaby, MPB, Maloya
• Mambo, Manila Sound, Marcha Carnavalesca, Marches
• Mariachi, Marimba, Mbalax, Mento, Merengue
• Miami Bass, Milonga, Min'yō, Mizrahi, Mo Lam
• Morna, Mugham, Musette, Música Criolla, Nagauta
• Neopagan, Nhạc Vàng, Nordic, Norteño, Nueva Cancion
• Nueva Trova, Népzene, Occitan, Ojkača, Ottoman Classical
• Overtone Singing, Pachanga, Pacific, Pagode, Pasodoble
• Persian Classical, Persian Pop, Philippine Classical
• Phleng Phuea Chiwit, Piobaireachd, Plena, Polka
• Porro, Qawwali, Quechua, Ranchera, Rara, Raï
• Rebetiko, Romani, Rōkyoku, Salegy, Salsa, Samba
• Samba-Canção, Sankyoku, Schlager, Sea Shanties
• Sean-nós, Sephardic, Seresta, Sertanejo, Shaabi
• Shidaiqu, Shinkyoku, Shomyo, Singeli, Soca
• Sokyoku, Son, Son Montuno, Sonero, Soukous
• Soul, Steel Band, Suomisaundi, Swamp Pop, Sámi Music
• Séga, Taarab, Tamburitza, Tamil Film Music, Tango
• Tejano, Thai Classical, Timba, Trallalero, Tribal
• Trova, Vallenato, Villancicos, Volksmusik, Waiata
• Yemenite Jewish, Yoruba, Zamba, Zarzuela
• Zemer Ivri, Zhongguo Feng, Zouk, Zydeco, Éntekhno
```
#### 21. Stage, Screen & Soundtracks (13 синонимов)
```
• Anison
• Cabaret
• Concert Film
• Movie Effects
• Music Hall
• Music Video
• Musical
• Score
• Soundtrack
• Theme
• Vaudeville
• Video Game Music
```
#### 22. Non-Music / Spoken Word / Miscellaneous (32 синонима)
```
• Audiobook
• Break-In
• Comedy
• Dialogue
• Education
• Educational
• Erotic
• Field Recording
• Health-Fitness
• Interview
• Karaoke
• Medical
• Military
• Monolog
• Novelty
• Parody
• Poetry
• Political
• Promotional
• Public Broadcast
• Public Service Announcement
• Radioplay
• Religious
• Sermon
• Special Effects
• Speech
• Spoken Word
• Sport
• Story
• Therapy
• Vocaloid
```
### Проверка целостности БД
**Все синонимы уникальны** — нет дубликатов внутри одной категории
**Нет пересечений** — синоним не может быть одновременно основным названием другой категории
**Полнота** — все ~810 стилей из Discogs распределены по 22 категориям
**Готовность к парсингу** — структура поддерживает будущую интеграцию с Discogs API
@@ -0,0 +1,195 @@
# Генерирование криптографической соли для OFFER_HASHIDS_SALT
## Для Development (локально)
Текущее значение в `.env`. Если база из dev будет перемещаться в продакшен, то соль не нужно будет заменять на уникальную для продакшена. А то все старые QR-коды перестанут работать.
Если все-таки нужно обновить соль, используйте Django custom command (см. ниже).
## Для Production (сервер)
**НИКОГДА** не используйте соль из примеров! Генерируйте новую для каждого окружения:
### Способ 1: Python (быстро)
```bash
python3 -c "import secrets; print(secrets.token_hex(16))"
```
Результат:
```
a7f3c82b9e1dEa6b5c8f2e3d0a9b4c7f
```
Скопируйте и обновите в `.env` на сервере:
```
OFFER_HASHIDS_SALT=a7f3c82b9e1dEa6b5c8f2e3d0a9b4c7f
```
### Способ 2: Linux/Mac (встроенный)
```bash
openssl rand -hex 16
```
### Способ 3: Более надежная соль (32 байта вместо 16)
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Результат (64 символа, очень стойко):
```
26aebe8af9efe7c5f81c64cb18846318cc81a83eaf15aedb7f4b8a80990c9676
```
## Важные правила
1. **Уникальная для каждого окружения** (каждой реализации LPON под каждого сейлера)
- Dev ≠ Staging ≠ Production
- Разные соли = разные коды для одного ID
2. **Никогда не меняйте в production!**
- Если поменяете соль → старые коды перестанут работать
- Существующие QR-коды станут невалидными
- Существующие ID в базе не будут декодироваться
- **Если все же нужно изменить:** используйте custom command `regenerate_offer_codes` (см. ниже)
3. **Хранить в .env (не в репозитории)**
- `.env` в .gitignore ✓
- `.env.example` содержит шаблон ✓
## Проверка качества соли
```python
import secrets
# Хорошая соль (минимум 32 символа, hex-формат)
salt = secrets.token_hex(16) # ✓
salt = secrets.token_hex(32) # ✓✓ еще лучше
# Плохие соли
salt = "my-password" # ✗ слишком короткая
salt = "12345678" # ✗ предсказуемая
salt = "qwerty123" # ✗ слабая энтропия
```
## Параметры OFFER_HASHIDS_MIN_LENGTH
| Кол-во оферов | min_length | Пример | Примечание |
|---------------|------------|--------------|---------------------------------------|
| До 1 000 | 4 | `a1bC` | Слишком короткие, может быть коллизии |
| До 10 000 | 5 | `a1bCd` | Хорошо для небольших каталогов |
| До 100 000 | 6 | `a1bCdE` | **РЕКОМЕНДУЕМО** для начала |
| До 1 000 000 | 8 | `a1bCdEfG` | Для больших каталогов |
| Более 1M | 10 | `a1bCdEfGhI` | Очень большие каталоги |
**Текущее значение:** `OFFER_HASHIDS_MIN_LENGTH=6` (оптимально)
## Как увеличить при необходимости?
Если вырос каталог:
1. Обновите в `.env`:
```
OFFER_HASHIDS_MIN_LENGTH=8
```
2. Перезагрузите приложение
3. **Старые коды останутся валидными!** (hashids декодирует любой код независимо от min_length)
4. Новые офферы будут кодироваться с длиной 8
## Django Custom Command: `regenerate_offer_codes`
**Расположение:** `lpon_site/frontend/management/commands/regenerate_offer_codes.py`
Используется для проверки, восстановления и перегенерации s_offer_code офферов.
### Три режима работы
#### 1. Проверка кодов (режим `--check`)
Декодирует все коды обратно в ID и проверяет корректность:
```bash
# Быстрая проверка
cd lpon_site && poetry run python manage.py regenerate_offer_codes --check
# С подробным выводом
cd lpon_site && poetry run python manage.py regenerate_offer_codes --check --verbose
```
#### 2. Исправление некорректных кодов (режим `--fix-broken`)
Обновляет только коды, которые не декодируются правильно:
```bash
# Пробный запуск (ничего не сохранит)
cd lpon_site && poetry run python manage.py regenerate_offer_codes --fix-broken --dry-run
# Реальное исправление
cd lpon_site && poetry run python manage.py regenerate_offer_codes --fix-broken
```
**Использование:** Когда некоторые коды повреждены или закодированы неправильно (например, после сбоя БД).
#### 3. Полное обновление всех кодов (по умолчанию)
Перегенерирует ВСЕ коды на основе текущего OFFER_HASHIDS_SALT:
```bash
# Пробный запуск
cd lpon_site && poetry run python manage.py regenerate_offer_codes --dry-run
# Реальное обновление
cd lpon_site && poetry run python manage.py regenerate_offer_codes
```
**ВНИМАНИЕ:** Используется только при смене OFFER_HASHIDS_SALT на production!
### Примеры использования
**Сценарий 1: Проверка целостности после сбоя**
```bash
# Сначала проверяем что сломалось
cd lpon_site && poetry run python manage.py regenerate_offer_codes --check
# Если есть некорректные коды - исправляем
cd lpon_site && poetry run python manage.py regenerate_offer_codes --fix-broken
```
**Сценарий 2: Миграция на новый сервер с новой солью**
```bash
# 1. Обновляем .env с новой солью
OFFER_HASHIDS_SALT=новая_соль_из_secrets
# 2. Перегенерируем все коды (пробный запуск сначала)
cd lpon_site && poetry run python manage.py regenerate_offer_codes --dry-run
# 3. Если хорошо - реальное обновление
cd lpon_site && poetry run python manage.py regenerate_offer_codes
# 4. Проверяем что все работает
cd lpon_site && poetry run python manage.py regenerate_offer_codes --check
```
### Опции команды
```
--check Проверить корректность всех кодов (декодировать обратно в id)
--fix-broken Обновить только коды которые не декодируются правильно
--dry-run Показать что будет изменено, но не сохранять
--verbose Показывать подробный прогресс для каждого оффера
```
Использование:
```bash
# Проверка
python manage.py regenerate_offer_codes --check
# Исправление
python manage.py regenerate_offer_codes --fix-broken
# Полное обновление (при смене соли)
python manage.py regenerate_offer_codes
```
@@ -0,0 +1,595 @@
# Паттерн создания справочников: автоматизация статей, синонимов и валидации
**Дата:** 2026-07-15
**Статус:** Актуально ✓
## Обзор
Система содержит 5 **справочных сущностей** (Reference Entities) которые описывают каталог:
| Модель | Таблица | Назначение | Связь с TbArticle |
|-------------|--------------|------------------------------------------|-------------------|
| Лейбл | TbLabel | Издатель, лейбл, дистрибьютор | 1:1 OneToOne |
| Исполнитель | TbArtist | Артист, группа, бренд | 1:1 OneToOne |
| Товар | TbItem | Альбом, релиз, носитель, аксессуар | 1:1 OneToOne |
| Стиль | TbMusicStyle | Музыкальный жанр | 1:1 OneToOne |
| Продавец | TbSeller | Магазин, продавец, издатель-дистрибьютор | 1:1 OneToOne |
Все 5 моделей используют **единый паттерн**:
1. Автоматическое создание связанной статьи (если нет)
2. Управление синонимами в метаданных
3. Валидация дубликатов через админку
4. Подготовка к парсингу с очередью конфликтов
## Архитектура: TbArticle как связующее звено
```text
┌─────────────────────────────────────────────────────────────┐
│ TbArticle │
│ (текст, SEO, картинка, слаг — для всех справочников) │
│ │
│ Поля: │
│ • s_article_title: "Label: Sony" │
│ • l_article_type: "label" │
│ • slug: "label-sony" (URL-идентификатор) │
│ • k_article_to_image: ссылка на картинку (логотип, фото) │
│ • seo_title, seo_description: для поисковиков и llm │
│ • s_article_teaser_html, s_article_content_html: текст │
└─────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
1:1 1:1 1:1 1:1
│ │ │ │
┌────────┴────┐ ┌────────┴─────┐ ┌────────┴────┐ ┌──┴──────────┐
│ TbLabel │ │ TbArtist │ │ TbItem │ │ TbMusicStyle│
│ │ │ │ │ │ │ │
│ s_label │ │ s_artist │ │ s_item │ │ s_style_name│
│ j_metadata │ │ j_metadata │ │ j_metadata │ │ j_metadata │
│ (синонимы) │ │ (синонимы) │ │ (синонимы) │ │ (синонимы) │
└─────────────┘ └──────────────┘ └─────────────┘ └─────────────┘
TbSeller аналогично, без синонимов (используется редко)
```
## Жизненный цикл справочника (на примере TbLabel)
### Фаза 1: Создание в админке
```python
# Админ создает новый лейбл через Django Admin форму
# Заполняет: s_label="Sony Music", j_label_metadata={"SYN_EN": [...]}
# При сохранении форма вызывает label.save()
```
### Фаза 2: Валидация синонимов (админка)
```python
# Функция validate_for_duplicates() проверяет:
# 1. EXACT_MATCH: s_label == s_label в других записях?
# Результат: "Sony Music" уже есть в базе
# Действие: Форма показывает ошибку, блокирует сохранение
# 2. FIND_IN_SYNONYM: s_label найден в синонимах других записей?
# Результат: "Sony Music" найден в синонимах записи TbLabel(pk=5)
# Действие: Админ видит список дубликатов, решает что делать
# 3. EXACT_SYNONYM_MATCH: синонимы разных лейблов совпадают?
# Результат: {"SYN_EN": ["Sony", "Sony Music"]} уже есть в записи TbLabel(pk=3)
# Действие: Ошибка, нужно исправить синонимы
#
# 4. PARTIAL_MATCH: частичное совпадение синонимов
# Не критичные совпадения. s_label и синонимы из метаданных разбиваются по словам, ищутся совпадения слов.
# Админ видит предупреждение. Для коротких слов (Inc, Ltd т.п.) может много ложных срабатываний. Админу показывается
# список слов-совпадений, он с чем они совпали, и решает что делать.
```
### Фаза 3: Автоматическое создание статьи
```python
# Если админ одобрил - вызывается label.save()
def save(self, *args, **kwargs):
# Шаг 1: Валидация синонимов
validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata')
# Шаг 2: Обновление синонимов метаданных
update_synonyms_in_metadata(self, 's_label', 'j_label_metadata')
# Шаг 3: Создание или получение связанной статьи
article = create_or_get_related_article(
self,
TbArticle.ArticleType.LABEL, # тип статьи
's_label', # основное поле
'j_label_metadata', # метаданные
'k_label_to_article' # FK на статью
)
self.k_label_to_article = article
# Шаг 4: Сохранение
super().save(*args, **kwargs)
```
**Что делает `create_or_get_related_article()`:**
```python
def create_or_get_related_article(
instance, # TbLabel, TbArtist и т.д.
article_type, # TbArticle.ArticleType.LABEL
main_field_name, # 's_label'
metadata_field_name, # 'j_label_metadata'
fk_field_name # 'k_label_to_article'
):
# 1. Генерируем название для статьи
article_title = f"Label: {instance.s_label}"
# 2. Ищем существующую статью
article = TbArticle.objects.filter(
s_article_title=article_title,
l_article_type=article_type
).first()
if article:
return article # Используем существующую
# 3. Создаём новую статью
article = TbArticle.objects.create(
s_article_title=article_title,
l_article_type=article_type,
b_article_published=True,
# slug генерируется автоматически в TbArticle.save()
)
return article
```
## Синонимы: управление и валидация
### Как формируются синонимы
Синонимы хранятся в JSONField `j_*_metadata` в формате:
```json
{
"SYN_EN": [
"The Beatles",
"Beatles",
"Beatles, The",
"The Fab Four"
],
"other_data": {...}
}
```
### Автоматическое обновление синонимов при сохранении
**Функция `update_synonyms_in_metadata()`:**
```python
def update_synonyms_in_metadata(instance, main_field_name, metadata_field_name):
"""
При каждом сохранении:
1. Если это новая запись: добавляем s_label в синонимы
2. Если s_label изменился: добавляем оба (старый и новый) в синонимы
3. Админ может редактировать синонимы через форму (приоритет админу)
"""
current_value = getattr(instance, main_field_name) # "Sony Music"
original_value = instance._original_value # "Sony"
# Получаем или создаём словарь метаданных
metadata = getattr(instance, metadata_field_name) or {}
synonyms = metadata.get(KEY_SYNONYM_EN, [])
# Добавляем новые синонимы
if original_value and original_value != current_value:
# Изменилось название - добавляем оба
synonyms.extend([original_value, current_value])
else:
# Новая запись - добавляем текущее
synonyms.append(current_value)
# Удаляем дубликаты и короткие слова
synonyms = [s for s in set(synonyms) if len(s) >= MIN_SYNONYM_WORD_LENGTH]
metadata[KEY_SYNONYM_EN] = sorted(synonyms)
setattr(instance, metadata_field_name, metadata)
```
### Валидация синонимов при сохранении
```python
# Проверяем: все ли синонимы уникальны?
validate_for_duplicates(
model_class=TbLabel,
instance_pk=label.id,
main_field_value=label.s_label,
metadata_dict=label.j_label_metadata,
main_field_name='s_label',
metadata_field_name='j_label_metadata',
)
# Если найдены дубликаты (FIND_IN_SYNONYM, EXACT_SYNONYM_MATCH):
# - В админке: показываем форму с ошибкой + список дубликатов
# - В парсере (будущее): создаём запись в ValidatorQueue для админа
```
## Админка: как это видит пользователь
### Создание нового лейбла
```
Админ заполняет форму:
┌────────────────────────────┐
│ Название лейбла* │
│ [ Sony Music ] │
│ │
│ Метаданные (JSON) │
│ { "SYN_EN": [...] } │
│ │
│ [ Сохранить ] │
└────────────────────────────┘
При сохранении:
1. Проверяются синонимы (EXACT_MATCH, FIND_IN_SYNONYM)
2. Если конфликт → показывается ошибка:
"Найдено совпадение! Существующие записи: [List]"
3. Если ОК → статья создаётся автоматически
"Лейбл создан. Связанная статья: Label: Sony Music"
```
### Обновление существующего лейбла
```
Админ меняет название (например, опечатка):
[ Sony Musik ] → [ Sony Music ]
1. Синонимы обновляются: добавляются оба варианта
2. Статья остаётся той же (slug не меняется)
3. Сохраняется историчность: "был Sony Musik, теперь Sony Music"
```
### Ошибки при сохранении
```
Сценарий: Админ пытается создать дубликат
┌─────────────────────────────────────────────┐
│ ОШИБКА: TbLabel.save() │
│ Найдено совпадение в синонимах! │
│ Разрешите на уровне админки. │
│ PK конфликтующих записей: [1, 5, 12] │
│ │
│ Подробно: "Sony Music" найдено в │
│ синонимах TbLabel(pk=5) │
└─────────────────────────────────────────────┘
Решение: Админ либо меняет название, либо
объединяет с существующей записью вручную.
```
## Применение ко всем 5 моделям
### TbLabel (Лейблы)
```python
class TbLabel(models.Model):
s_label: str (уникальный) # "Sony Music", "Мелодия"
j_label_metadata: JSON # {"SYN_EN": [...]}
k_label_to_article: FK TbArticle # автоматическое создание
# ArticleType: LABEL
# Формат названия: "Label: Sony Music"
```
### TbArtist (Исполнители)
```python
class TbArtist(models.Model):
s_artist: str (уникальный) # "The Beatles", "David Bowie"
j_artist_metadata: JSON # {"SYN_EN": [...]}
k_artist_to_article: FK TbArticle # автоматическое создание
# ArticleType: ARTIST
# Формат названия: "Artist: The Beatles"
```
### TbMusicStyle (Стили)
```python
class TbMusicStyle(models.Model):
s_style_name: str (уникальный) # "Rock", "Jazz"
j_style_metadata: JSON # {"SYN_EN": [...]}
k_style_to_article: FK TbArticle # автоматическое создание
# ArticleType: STYLE
# Формат названия: "Style: Rock"
```
### TbItem (Товары/Релизы)
```python
class TbItem(models.Model):
s_item: str (уникальный) # "Abbey Road (LP)"
j_item_metadata: JSON # {"SYN_EN": [...]}
k_item_to_article: FK TbArticle # автоматическое создание
# ArticleType: ITEM
# Формат названия: "Item: Abbey Road (LP)"
```
### TbSeller (Продавцы)
```python
class TbSeller(models.Model):
s_seller: str (уникальный) # "Клюква Records", "Amazon"
j_seller_metadata: JSON # (синонимы не используются)
k_seller_to_article: FK TbArticle # автоматическое создание
# ArticleType: SELLER
# Формат названия: "Seller: Клюква Records"
# НЕ имеет синонимов (редко меняется, используется редко)
```
## Парсинг (будущее): интеграция с очередью конфликтов
### Сценарий: парсер вытащил данные из Discogs
```python
# Парсер получил из Discogs:
incoming_data = {
's_artist': 'The Beatles',
'j_artist_metadata': {
'SYN_EN': ['Beatles', 'The Fab Four'],
'DISCOGS_ID': 123456,
'COUNTRY': 'UK',
'FORMED': '1960',
}
}
# Парсер пытается создать TbArtist(**incoming_data)
artist = TbArtist(**incoming_data)
artist.save() # Вызывает валидацию
# Если есть конфликт (FIND_IN_SYNONYM, EXACT_SYNONYM_MATCH):
# - Парсер ловит ValidationError
# - Создаёт запись в ValidatorQueue
# - Админ получает уведомление про конфликт
# - Админ решает: создать новую / объединить / пропустить
```
### Структура ValidatorQueue для справочников
```python
ValidatorQueue(
k_source=excel_file_source,
l_model_type='TbArtist',
l_conflict_type='FIND_IN_SYNONYM', # тип конфликта
model_pk=None, # это новая запись (или PK существующей)
j_incoming_data={
's_artist': 'The Beatles',
'j_artist_metadata': {'SYN_EN': [...], 'DISCOGS_ID': ...}
},
j_duplicates=[
{
'pk': 15,
's_artist': 'Beatles',
'matched_value': 'The Beatles',
'match_type': 'FIND_IN_SYNONYM',
'reason': 'Найдено в синонимах'
}
],
l_status='pending', # Ждёт решения админа
s_admin_decision=None, # Админ ещё не решил
)
```
### Решения админа
```
1. "create_new" — создать новую запись (с дубликатом)
Когда: Если это действительно разные сущности (например,
The Beatles (группа) vs The Beatles (альбом))
2. "merge" — объединить с существующей
Когда: Если это одна и та же сущность, только разные названия
Действие: Добавить синонимы в существующую запись
3. "skip" — пропустить
Когда: Данные некорректны или дубликаты действительно
Действие: Ничего не создавать, просто пропустить
```
## Метаданные для парсинга: проверить, можно что-то вытащить из внешних источников
### Из Discogs (для всех справочников)
```json
{
"DISCOGS_ID": 123456, // ID записи в Discogs
"DISCOGS_URL": "https://...", // Ссылка на Discogs
"DISCOGS_UPDATED": "2024-01-15",// Дата обновления на Discogs
"SYN_EN": [ // Синонимы на английском
"Alternative name",
"Another variant"
]
}
```
### Для TbArtist (исполнители) — из Discogs, MusicBrainz
```json
{
"ARTIST_TYPE": "Group", // Solo, Group, Orchestra, Character
"FORMED": "1960", // Год образования
"DISBANDED": null, // Год распада (если есть)
"COUNTRY": "GB", // Страна (ISO 3166-1)
"AREA": "United Kingdom", // Регион
"MEMBERS": [ // Участники (для групп)
{"name": "John Lennon", "role": "Vocals"},
{"name": "Paul McCartney", "role": "Bass"},
],
"WEBSITE": "https://beatles.com", // Официальный сайт
"WIKIPEDIA": "https://en.wikipedia.org/...", // Статья
"MUSICBRAINZ_ID": "uuid-here", // MusicBrainz ID
"BIOGRAPHY": "Краткая биография...", // Текст для статьи
"IMG_URL": "https://...", // Ссылка на изображение
"YANDEX_MUSIC_ID": 123456, // ID на Яндекс.Музыке
"SPOTIFY_ID": "spotify-id", // ID на Spotify
"SYN_EN": [...],
"SYN_RU": [...] // Синонимы на русском
}
```
### Для TbLabel (лейблы) — из Discogs, MusicBrainz
```json
{
"LABEL_TYPE": "Production", // Production, Reissue, Distributor
"FOUNDED": "1945", // Год основания
"COUNTRY": "USA", // Страна
"HEADQUARTERS": "New York, NY", // Штаб-квартира
"PARENT_LABEL": 456789, // ID материнского лейбла (Discogs)
"WEBSITE": "https://sony.com", // Официальный сайт
"WIKIPEDIA": "https://...", // Статья
"CONTACT_EMAIL": "info@...", // Email контакта
"CONTACT_PHONE": "+1-234-567", // Телефон
"COMPANY_SIZE": "Large", // Small, Medium, Large, Mega
"DISCOGS_CATALOG_PREFIX": "SR", // Префикс каталога
"IMG_LOGO": "https://...", // Логотип
"SYN_EN": [...],
"SYN_RU": [...]
}
```
### Для TbItem (релизы/альбомы) — из Discogs, MusicBrainz
```json
{
"ITEM_TYPE": "Album", // Album, Single, EP, Compilation
"RELEASE_DATE": "1969-09-26", // Дата выхода
"FORMAT": "Vinyl LP", // Vinyl LP, CD, Cassette, Digital
"COUNTRY": "GB", // Страна выхода
"BARCODE": "093624892340", // Штрихкод
"DISCOGS_ID": 987654, // ID мастер-релиза
"MUSICBRAINZ_ID": "uuid", // MusicBrainz ID
"GENRES": ["Rock", "Pop"], // Жанры (для синхронизации с TbMusicStyle)
"CATALOG_NUMBER": "SR-123", // Каталожный номер издателя
"LANGUAGE": "English", // Язык вокала/текста
"TRACKS_COUNT": 14, // Количество треков
"DURATION": "42:30", // Длительность альбома
"RATING": 4.5, // Рейтинг на Discogs (1-5)
"NOTES": "Limited edition...", // Примечания
"IMG_COVER": "https://...", // Обложка альбома
"YANDEX_MUSIC_ID": 123456, // ID на Яндекс.Музыке
"SPOTIFY_ID": "spotify-id", // ID на Spotify
"SYN_EN": [...],
"SYN_RU": [...]
}
```
### Для TbMusicStyle (жанры/стили) — из Discogs, MusicBrainz, Wikipedia
```json
{
"STYLE_TYPE": "Primary", // Primary, Sub-genre
"DESCRIPTION": "Rock music is...", // Описание жанра
"ORIGIN_COUNTRY": "USA", // Страна происхождения
"ORIGIN_YEAR": "1950s", // Период возникновения
"PARENT_GENRE": "Rock", // Основной жанр (для иерархии)
"RELATED_STYLES": [ // Связанные жанры
"Hard Rock",
"Progressive Rock"
],
"NOTABLE_ARTISTS": [ // Известные исполнители
"The Beatles",
"Rolling Stones"
],
"WIKIPEDIA": "https://...", // Статья в Википедии
"IMG_ILLUSTRATION": "https://...", // Иллюстрация для жанра
"SYN_EN": [...],
"SYN_RU": [...]
}
```
### Для TbSeller (продавцы) — из веб-скрейпинга, API или Excel (для excel и ручного ввода заполнять руками)
```json
{
"SELLER_TYPE": "Store", // Store, Label, Distributor, Marketplace
"FOUNDED": "2005", // Год основания
"COUNTRY": "RU", // Страна
"CITY": "Moscow", // Город
"ADDRESS": "ul. Pushkina, 42", // Адрес
"WEBSITE": "https://example.com", // Сайт
"EMAIL": "sales@example.com", // Email
"PHONE": "+7-495-123-45-67", // Телефон
"SOCIAL_VK": "https://vk.com/...", // VKontakte
"SOCIAL_INSTAGRAM": "https://...", // Instagram
"SHIPPING_COUNTRIES": [ // Страны доставки
"RU", "BY", "KZ"
],
"PAYMENT_METHODS": [ // Методы оплаты
"Card", "Bank transfer", "Cash"
],
"RETURNS_POLICY": "7 days", // Политика возврата
"RATING": 4.8, // Рейтинг на платформе
"REVIEWS_COUNT": 342, // Количество отзывов
"IMG_LOGO": "https://...", // Логотип магазина
}
```
## Жизненный цикл в нескольких слов
```
Админка Парсер (будущее)
│ │
├─ Заполняет форму ├─ Читает Discogs/Excel
│ (название, синонимы) │ (вытаскивает данные)
│ │
├─ Нажимает "Сохранить" ├─ Вызывает Model.save()
│ │
├─ Валидация синонимов ├─ Валидация синонимов
│ (FIND_IN_SYNONYM?) │ (конфликты?)
│ │
├─ Ошибка? → показать админу ├─ Конфликт? → ValidatorQueue
│ │
├─ OK? → создать статью ├─ OK? → создать модель
│ автоматически │ + статья автоматически
│ │
└─ Данные целостны и готовы └─ Админ решает позже
```
## Преимущества подхода
**Единообразие**: 5 моделей работают по одному паттерну
**Автоматизация**: Статьи создаются сами, не нужна ручная работа
**Валидация**: Дубликаты и синонимы проверяются автоматически
**Контроль**: Админ видит конфликты и может принять решение
**Масштабируемость**: Легко добавить другие справочники (если нужны)
**SEO-дружественно**: Каждый справочник имеет URL через слаг
**История**: Изменения в названиях сохраняются через синонимы
**Готовность к парсерам**: Структура поддерживает будущую интеграцию
## Файлы реализации
- **Модели**: `lpon_site/frontend/models.py` (TbLabel, TbArtist, TbItem, TbMusicStyle, TbSeller)
- **Валидаторы**: `lpon_site/frontend/utils_validators.py` (validate_for_duplicates)
- **Хелперы**: `lpon_site/frontend/utils.py` (create_or_get_related_article, update_synonyms_in_metadata)
- **Админка**: `lpon_site/frontend/admin.py` (LabelAdmin, ArtistAdmin и т.д.)
- **Парсер** (будущее): `lpon_site/parser/models.py` (ValidatorQueue), `tasks.py` (Celery задачи)
## TODO: интеграция парсера
- [ ] Создать Celery tasks для парсинга Discogs/Excel
- [ ] Реализовать ValidatorQueue с админ-интерфейсом
- [ ] Добавить поддержку очереди конфликтов в моделях
- [ ] Интегрировать сбор метаданных из Discogs API
- [ ] Документировать API для парсера
- [ ] Написать тесты для валидации и парсинга
+43
View File
@@ -0,0 +1,43 @@
# Архитектурные решения: Роутинг, SEO-редиректы и Обработка Ошибок
**Дата и время обновления:** 03.08.2026 00:39
**Проект:** LPON.RU (Django 6.0+, Python 3.12)
**Статус:** Согласованная архитектура
---
## 1. Стратегия Роутинга и SEO
### 1.1. Плоский единый роутер из корня (`/<slug>/`)
- Отказываемся от искусственных служебных префиксов в URL (например, `/hub/<slug>/`). Все статьи, хабы, музыкальные стили и категории живут на первом уровне: `lpon.ru/vinyl/`, `lpon.ru/cassettes/`, `lpon.ru/about/`.
- В `urls.py` в самом конце списка роутов устанавливается универсальный Catch-All роут:
```python
path('<slug:slug>/', views.UniversalSlugView.as_view(), name='universal_slug')
```
### 1.2. Обработка отсутствующих слагов: Настоящий HTTP 404 + Подсказка для Админа
- **Проблема Soft 404:** Возврат статуса `200 OK` для несуществующих слагов с заглушкой ("Страница наполняется") вреден для SEO (поисковики индексируют Soft 404 и пессимизируют сайт).
- **Решение:**
1. Диспетчер ищет `slug` в `TbArticle`.
2. Если статья со слагом не найдена ни в основных слагах, ни в истории переименований — сервер **ОБЯЗАТЕЛЬНО возвращает честный статус `HTTP 404 Not Found`**.
3. Для обычного пользователя рендерится аккуратная страница ошибки 404 ("Страница не найдена").
4. Для авторизованного администратора (`request.user.is_staff`) на той же странице 404 выводится специальный информационный блок-подсказка:
> *"Вы вошли как администратор. По адресу `/cassettes/` не найдена статья. Создайте в админке `TbArticle` со слагом `cassettes` (тип HUB), и страница оживёт!"*
### 1.3. Переименование слагов и История Алиасов (HTTP 301 Permanent Redirect)
Возможно овер-кил.
- **Проблема смены слагов:** Если администратор переименует слаг статьи (например, с `cassettes` на `audio-cassettes`), старые внешние ссылки и поисковые индексы выдадут 404.
- **Решение (История слагов):**
1. В `TbArticle` ведётся список прошлых слагов (например, в JSON-метаданных `j_article_metadata['old_slugs']` или отдельной таблице алиасов).
2. Если запрошенный `slug` не найден в основном поле `s_article_slug`, диспетчер проверяет его наличие в истории `old_slugs`.
3. Если слаг найден в истории — сервер мгновенно возвращает **HTTP 301 Permanent Redirect** на актуальный канонический URL статьи (`/audio-cassettes/`).
### 1.4. SEO-оптимизация и Канонические редиректы для Профильных Разделов
- Если страница найдена по общему роуту `/<slug>/`, но её `l_article_type` указывает на строго профильный раздел (например, `l_article_type == 'blog'`, `'read'` или `'item'`), и у этой сущности есть свой канонический префиксный путь (например, `/read/<slug>/` или `/item/<slug>/`):
- Сервер выполняет **HTTP 301 Permanent Redirect** с общего пути `/<slug>/` на канонический адрес `/read/<slug>/`.
- **Зачем это нужно:**
- Исключает появление дублей страниц в индексе поисковых систем (Яндекс / Google).
- Чётко разграничивает контентные материалы (новости, статьи) и торговые предложения/хабы.
+45
View File
@@ -0,0 +1,45 @@
# Архитектурные решения: Анонимные Заказы (Stateless Cart & Orders)
**Дата и время создания:** 02.08.2026 23:54
**Проект:** LPON.RU (Django 6.0+, Python 3.12)
**Статус:** Согласованная архитектура
---
## 1. Бесконтактная Корзина и Заказы без Персональных Данных (Privacy-First / Stateless Orders)
### 1.1. Концепция полного отказа от ПДн
- **Проблема:** Хранение персональных данных клиентов (ФИО, телефон, адрес доставки, e-mail, паспорта) накладывает жесткие юридические обязательства (152-ФЗ), требует аттестации БД, согласий на обработку ПДн и несёт риски утечек.
- **Решение:** Полный отказ от хранения ПДн клиентов в базе данных LPON.RU. Сайт работает как смарт-витрина и генератор заказов, а персональные данные либо вообще не собираются, либо обрабатываются внешними специализированными сервисами (Авито, СДЭК, Яндекс.Доставка, эквайринг).
### 1.2. Архитектура клиентоориентированной корзины (Stateless Cart)
1. **Хранение состава корзины:**
- Состав корзины хранится исключительно на стороне клиента: в `Session Cookies` или `LocalStorage`.
- В БД LPON.RU записи корзины пользователей НЕ создаются.
2. **Идентификация товаров:**
- В корзине сохраняется список уникальных кодов офферов (`s_offer_code`, сгенерированных через Hashids) и их количество: `{"codes": {"4gfFCJ": 1, "9xK2Lm": 2}}`.
- Все данные о цене, названии и наличии запрашиваются у сервера динамически по списку `s_offer_code`.
3. **DSL-модуль корзины (`!cart-app`):**
- Страница `/cart/` создаётся как стандартная статья-хаб `TbArticle` (тип `HUB`), в метаданные которой встроен динамический модуль `["!cart-app"]`.
- Это позволяет нативно обвешивать страницу корзины маркетинговыми блоками через DSL (например, выводить снизу карусель товаров `["cassettes-sale", "carousel"]`).
### 1.3. Способы оформления заказа без сохранения ПДн в БД LPON.RU
#### Вариант А: Оформление через Авито / Маркетплейсы
- По кнопке «Оформить» сайт формирует ссылку или готовый текст заказа для перехода на профиль продавца на Авито.
- Покупатель оформляет сделку через безопасную сделку Авито-Доставки. LPON.RU вообще не касается данных о доставке и оплате.
#### Вариант Б: Интеграция со СДЭК / Яндекс.Доставкой через виджет
- При выборе доставки СДЭК прямо на странице корзины открывается официальный виджет СДЭК. Посылка "наложенным платежом" (что, кстати, защитит от недобросовестных покупателей, которые получают одно, а возвращают другое. Покупатель все проверяет при получении, а деньги СДЭК переводит только после подтверждения получения).
- Клиент вводит свои данные (ФИО, пункт выдачи, телефон) **напрямую в формы виджета СДЭК**.
- Сервер LPON.RU получает от API СДЭК только **Трек-номер (Waybill ID)**.
- На стороне LPON.RU в журнале анонимных транзакций сохраняется лишь минимальная агрегированная запись:
- Идентификатор заказа (`Order Hash`);
- Состав офферов (`s_offer_code`);
- Трек-номер СДЭК;
- Статус оплаты/доставки.
- *Данные получателя в БД LPON.RU отсутствуют.*
#### Вариант В: Анонимный статус заказа по Токену
- Покупатель получает уникальную ссылку вида `lpon.ru/order/<order_hash>/` (или хранит токен в куках/LocalStorage).
- По этой ссылке клиент может проверить текущий статус посылки (информация подтягивается из API СДЭК/Почты по трек-номеру в реальном времени) без необходимости авторизации и ввода личных данных.
+43
View File
@@ -0,0 +1,43 @@
# План задач (TODO)
**Дата и время обновления:** 03.08.2026 22:24
**Проект:** LPON.RU (Django 6.0+, Python 3.12)
---
## 1. Архитектура и База данных (Бэкенд)
- [ ] **Реализовать модель алиасов слагов `TbArticleAlias` (для HTTP 301 редиректов при переименовании слагов)**
- *Описание:* Для SQLite вариант с отдельной моделью и B-Tree индексом (`s_old_slug` -> `ForeignKey(TbArticle)`) работает значительно быстрее поиска по JSON-полю (`O(1)` вместо сканирования таблицы).
- *Детали:*
- Создать модель `TbArticleAlias` (`s_old_slug = SlugField(unique=True, db_index=True)`, `k_article = ForeignKey(TbArticle)`).
- При изменении `s_article_slug` в админке автоматически сохранять предыдущий слаг в `TbArticleAlias`.
- В `UniversalSlugView` добавить двухэтапный поиск:
1. Поиск по `TbArticle.s_article_slug` -> если найден, рендерим (или 301 на канонический URL).
2. Если не найден, поиск по `TbArticleAlias.s_old_slug` -> если найден, рендерим `HTTP 301 Permanent Redirect` на актуальный URL статьи.
3. Если не найден нигде -> `HTTP 404` (с блоком-подсказкой для `request.user.is_staff`).
- *Приоритет:* Полезная архитектурная фича на будущее (низкий / плановый приоритет).
---
## 2. DSL-парсер Страниц-Хабов и Динамических Модулей
- [ ] **Замена списочных вьюх/шаблонов на универсальные DSL-хабы**
- *Описание:* Отказаться от создания специализированных views и templates под каждый раздел с карточками/списками (например, `/info/`, `/blog/`). Любой список разделов или статей становится частным случаем хаба со статьей `l_article_type == 'HUB'`.
- *Детали:*
- Отдельные списочные вьюхи (например, `txt_articles_list`) упраздняются в пользу единого диспетчера хабов `HubView`.
- Все сущности и статьи генерируют свои канонические URL через `get_absolute_url()` модели `TbArticle`, исключая путаницу префиксов в ссылках внутри блоков хаба.
- *Приоритет:* Средний (по мере реализации DSL-парсера).
- [ ] **Реализовать DSL-парсер Страниц-Хабов (`j_article_metadata['hub']`)**
- Поддержка позиционных кортежей `["slug", "view_mode", limit, "sort", extra]`.
- Поддержка динамических модулей по префиксу `!` (`["!cart-app"]`, `["!promo-mandarin"]`).
- Защита от рекурсии (`visited_slugs`, `MAX_RECURSION_DEPTH = 2`).
- *Приоритет:* Высокий
## 3. Анонимные Заказы и Бесконтактная Корзина (Stateless Orders)
- [ ] **Бесконтактная Корзина и Заказы (Stateless Orders)**
- Реализация модуля `!cart-app` на клиенте (Cookies/LocalStorage) без записи ПДн клиентов в БД сайта.
- Интеграция с виджетом СДЭК и сохранение только трек-номеров.
- *Приоритет:* Средний (для MVP можно временно использовать email-переписку).
+74
View File
@@ -0,0 +1,74 @@
# Слоган рядом с логотипом
Разнести SEO-нагрузку по title, meta, скрытым или структурированным заголовкам <h1> / <h2> и **слоган**
Каждый раздел (группа товаров) будет иметь свой слоган, который отображаться рядом с логотипом в шапке сайта.
Название LPON изначально задумывалось как аббревиатура от LP (Long Play — виниловый альбом) или ON (включение).
# Варианты слоганов для разных разделов сайта:
* Общий:
* Long Play ON
* **LPON: для тех, кто слышит разницу**
* Long Play ON: не просто звук — опыт
* LPON: всё, что звучит по‑настоящему
* LPON: вес и ощущение звука
* LPON: ощущение и вес звука
* LPON: вес и ощущения звука, который не передать цифрой
* Винил (LP):
* **Пластинки: 33⅓ и не только**
* _Пластинки: 33⅓ и две стороны удовольствия_.
* Пластинки: крутим не спеша, слушаем внимательно.
* Пластинки: всё, что звучит по‑настоящему.
* **Винил: Магия 33 оборотов и глубокого звука.**
* Винил: Больше чем музыка — твой личный ритуал.
* Винил: Глубина в каждой канавке.
* Кассеты (CC):
* Кассеты: C-46, C-54, C-60, C-90 — длина имеет значение.
* Кассеты: Лента помнит всё.
* Кассеты: C-90 помнит всё.
* Кассеты: C-90 и не только.
* Кассеты: Тот самый «тёплый» звук С-90.
* Кассеты: Магия TypeI 120μs EQ
* Кассеты: Переписал и слушай
* Кассеты: В сердце со скоростью 1⅞" в секунду
* Кассеты: 1⅞ дюйма/сек — аналоговый пульс.
* C‑90. Переворачивай!
* Кассеты: музыка с характером.
* Кассеты: не просто запись — атмосфера
* **Кассеты: Тёплый шум твоих любимых записей.**
* Кассеты: Атмосфера, застывшая на магнитной ленте.
* Кассеты: Перемотай к самому главному.
* Компакт-диски (CD):*
* **CD: без сжатия, без компромиссов.**
* CD 16bit/44,1kHz и не только
* CD: 16bit/44.1kHz — золотой стандарт.
* CD: чистый поток 44.1 kHz.
* CD: звук, собранный по битам.
* CD: 16bit/44,1kHz — точность без пафоса.
* Мини-диски (MD):
* **MD: цифровой формат с аналоговым характером.**
* MD ATRAC и не только
* MD: цифровая запись с аналоговым сердцем.
* MD: маленький диск — большой звук.
* Катушки (RL):
* **Катушки: Так писали в студиях. Так слушали дома.**
* RL: 1/4 дюйма, 15ips и не только
## Финальный вариант
* Общий: LPON: для тех, кто слышит разницу.
* Винил: Глубина звука в каждом обороте.
* Винил: Две стороны удовольствия — 33⅓ и не только.
* Кассеты: Тёплое дыхание аналоговой ленты.
* Кассеты: Живой звук на 4,76 см/с.
* Кассеты: Живой звук на С-90.
* CD: 16bit / 44.1kHz — Без сжатия, без компромиссов.
* MD: Цифровой формат с аналоговым характером.
* Катушки: Так писали в студиях. Так слушали дома.
+414
View File
@@ -0,0 +1,414 @@
# Архитектура парсера и валидации данных
**Дата:** 2026-07-15
**Последний обновленно:** 2026-07-15
**Статус:** В разработке ✏️
## Обзор проблемы
Система должна поддерживать импорт данных из различных источников:
- Excel/CSV файлы от продавцов и издателей
- Парсинг URL-страниц с каталогами
- Ручной ввод данных в админке
- Будущие API интеграции
**Основной вызов:** Валидация и обнаружение конфликтов (дубликаты, совпадения в синонимах) требует **пользовательского участия** для принятия решений. Нельзя просто блокировать или автоматически удалять/объединять данные.
## Текущее состояние (MVP)
### Модели и структура
```
┌─ TbSource (Excel, CSV, URL) ──┐
│ s_source_name │
│ l_source_type (excel/csv/url)│
│ k_source_to_seller ──────────┼──► TbSeller
│ source_file (FilerFileField) │
│ s_source_url │
│ j_source_metadata (struktura)│ (metadata описывает столбцы, вкладки и т.д.)
└─────────────────────────────────┘
TbOffer ◄──┐
TbSource ──┴─► TbItem ──────► TbArticle (текст, SEO, slug, картинка)
└──► TbLabel, TbArtist, TbMusicStyle
(все через TbArticle)
```
### Валидация (текущая реализация)
**Файл:** `lpon_site/frontend/utils_validators.py`
**Функция:** `validate_for_duplicates()`
- Проверяет основное поле (s_label, s_artist и т.д.)
- Проверяет синонимы в j_*_metadata[SYN_EN]
- Возвращает список найденных дубликатов с типом совпадения
**Типы совпадений:**
1. **EXACT_MATCH** — точное совпадение основного поля (s_label == s_label)
2. **FIND_IN_SYNONYM** — основное поле текущей записи найдено в синонимах другой
3. **EXACT_SYNONYM_MATCH** — синонимы текущей записи совпадают с синонимами другой
**Где вызывается:**
-В админке: `admin.py` (переопределение `clean()` формы) → показывает ошибку пользователю
-В моделях: `save()` методы → выбрасывают `ValidationError`
-В парсере: **НЕТ** (парсер еще не создан)
### Конфликты синонимов (TODO)
**Сценарий 1: FIND_IN_SYNONYM**
```python
# В БД уже есть
Artist(pk=1, s_artist="Beatles", j_artist_metadata={"SYN_EN": ["The Beatles", "Beatles"]})
# Парсер хочет создать
Artist(pk=None, s_artist="The Beatles", j_artist_metadata={"SYN_EN": []})
# Валидатор: основное поле "The Beatles" найдено в синонимах существующей записи!
# Текущее поведение: ValidationError (блокировка)
# TODO: Отправить в очередь, ждать решения админа
```
**Сценарий 2: EXACT_SYNONYM_MATCH**
```python
# В БД
Label(pk=1, s_label="Sony", j_label_metadata={"SYN_EN": ["Sony Records", "Sony Music"]})
# Парсер хочет
Label(pk=None, s_label="Sony Music", j_label_metadata={"SYN_EN": ["Sony Records", "Sony Music"]})
# Валидатор: синонимы совпадают!
# Текущее поведение: ValidationError
# TODO: Отправить в очередь, ждать решения админа
```
## Архитектура парсера (требуемая)
### Компоненты
```
┌──────────────────────────────────────────────────────────────┐
│ DJANGO ADMIN │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Приложение "Parser" (новое приложение) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Очередь валидации (ValidatorQueue model) │ │ │
│ │ │ Содержит: состояние экземпляра, ошибки, решение... │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Admin list view + inline actions │ │ │
│ │ │ - Просмотр конфликта (дубликаты) │ │ │
│ │ │ - Кнопка "Создать новую запись" │ │ │
│ │ │ - Кнопка "Объединить/обновить синонимы" │ │ │
│ │ │ - Кнопка "Пропустить" │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ БРОКЕР ОЧЕРЕДИ (Redis) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ parser:validation:pending (ZSET) │ │
│ │ parser:validation:{queue_id}:data (STRING/JSON) │ │
│ │ parser:validation:{queue_id}:duplicates (JSON) │ │
│ │ parser:validation:{queue_id}:solution (STRING) │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ ПАРСЕР (Celery Task) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ parse_excel() / parse_csv() / parse_url() │ │
│ │ │ │
│ │ Логика: │ │
│ │ 1. Читать источник (файл/URL) │ │
│ │ 2. Нормализовать данные │ │
│ │ 3. Валидировать через validate_for_duplicates() │ │
│ │ 4. Если конфликт → создать запись в ValidatorQueue │ │
│ │ 5. Если OK → создать модель (TbLabel, TbArtist и т.д.)│ │
│ │ 6. Отправить уведомление админу (Redis/сигнал) │ │
│ │ 7. Продолжить обработку следующей строки │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ СИГНАЛЫ И УВЕДОМЛЕНИЯ (Django signals) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ post_validate_conflict → Redis уведомление админу │ │
│ │ post_admin_decision → Обновление очереди │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### Таблица ValidatorQueue (новая модель)
```python
class ValidatorQueue(models.Model):
"""
Очередь конфликтов, требующих решения админа.
"""
class Status(TextChoices):
PENDING = 'pending' # Ждёт решения админа
APPROVED = 'approved' # Админ одобрил - выполнить действие
REJECTED = 'rejected' # Админ отклонил
MERGED = 'merged' # Данные объединены с существующей записью
SKIPPED = 'skipped' # Заметка: пропущено
class ConflictType(TextChoices):
FIND_IN_SYNONYM = 'find_in_synonym' # Основное поле в синонимах
EXACT_SYNONYM_MATCH = 'exact_synonym_match' # Синонимы совпадают
OTHER = 'other'
# Основные поля
id = BigAutoField(primary_key=True)
k_source = ForeignKey(TbSource, ...) # Откуда пришли данные
l_conflict_type = CharField(choices=ConflictType)
# Данные для конфликта
l_model_type = CharField() # Какая модель (TbLabel, TbArtist и т.д.)
model_pk = IntegerField(null=True) # PK существующей записи в БД
j_incoming_data = JSONField() # Данные от парсера (весь объект)
j_duplicates = JSONField() # Результат validate_for_duplicates()
# Решение админа
l_status = CharField(choices=Status, default=Status.PENDING)
s_admin_decision = CharField() # 'create_new' / 'merge' / 'skip'
s_admin_notes = TextField() # Комментарий админа
# Сервисные поля
t_created = DateTimeField(auto_now_add=True)
t_resolved = DateTimeField(null=True)
k_resolved_by = ForeignKey(User, null=True) # Какой админ принял решение
class Meta:
verbose_name = 'Конфликт валидации'
ordering = ['-t_created']
indexes = [
Index(fields=['l_status', '-t_created']),
Index(fields=['k_source', 'l_status']),
]
```
## Процесс работы
### Фаза 1: Импорт данных (парсер)
```python
# tasks.py (Celery)
@celery.task
def parse_and_validate_excel(source_id: int):
"""
1. Читает Excel из TbSource
2. Парсит строки
3. Валидирует каждую запись
4. Создаёт или отправляет в очередь
"""
source = TbSource.objects.get(pk=source_id)
for row in read_excel(source.source_file):
try:
# Нормализуем данные
artist_data = {
's_artist': row['Artist Name'],
'j_artist_metadata': {'SYN_EN': [row.get('Aliases', '')]}
}
# Валидируем
duplicates = validate_for_duplicates(
model_class=TbArtist,
instance_pk=None,
main_field_value=artist_data['s_artist'],
metadata_dict=artist_data['j_artist_metadata'],
main_field_name='s_artist',
metadata_field_name='j_artist_metadata',
)
if duplicates:
# КОНФЛИКТ! Отправляем в очередь
queue_item = ValidatorQueue.objects.create(
k_source=source,
l_model_type='TbArtist',
l_conflict_type=duplicates[0][VALIDATE_KEY__MATCH_TYPE],
j_incoming_data=artist_data,
j_duplicates=duplicates,
)
# Отправляем сигнал (Redis уведомление админу)
send_to_redis_queue('parser:conflicts:new', {
'queue_id': queue_item.id,
'model': 'TbArtist',
'conflict': duplicates[0]['match_type'],
'data': artist_data,
})
else:
# Нет конфликта - создаём запись
artist = TbArtist.objects.create(**artist_data)
logger.info(f"Artist created: {artist.id}")
except Exception as e:
logger.error(f"Parse error in row {row}: {e}")
continue
```
### Фаза 2: Админка (приложение Parser)
```python
# admin.py (новое приложение parser)
@admin.register(ValidatorQueue)
class ValidatorQueueAdmin(admin.ModelAdmin):
"""
Админка для работы с очередью конфликтов.
"""
list_display = [
'id', 'l_model_type', 'l_conflict_type', 'l_status',
'source_link', 'duplicates_summary', 't_created'
]
list_filter = ['l_status', 'l_conflict_type', 'l_model_type', 't_created']
readonly_fields = ['j_incoming_data', 'j_duplicates', 'k_source']
fieldsets = (
('Конфликт', {
'fields': ['l_model_type', 'l_conflict_type', 'k_source', 'model_pk']
}),
('Входящие данные', {
'fields': ['j_incoming_data']
}),
('Найденные дубликаты', {
'fields': ['j_duplicates']
}),
('Решение админа', {
'fields': ['s_admin_decision', 's_admin_notes', 'l_status']
}),
)
actions = ['action_create_new', 'action_merge_synonyms', 'action_skip']
def action_create_new(self, request, queryset):
"""Админ решил: создать новую запись, игнорируя дубликаты"""
for item in queryset:
model_class = get_model_class(item.l_model_type)
instance = model_class(**item.j_incoming_data)
instance.save(skip_validation=True) # Пропускаем валидацию
item.l_status = ValidatorQueue.Status.APPROVED
item.s_admin_decision = 'create_new'
item.k_resolved_by = request.user
item.t_resolved = now()
item.save()
```
### Фаза 3: Обработка решения админа
```python
# signals.py (новое приложение parser)
@receiver(post_save, sender=ValidatorQueue)
def on_admin_decision(sender, instance, created=False, **kwargs):
"""
Обработать решение админа и применить действие.
"""
if created or instance.l_status != ValidatorQueue.Status.APPROVED:
return
if instance.s_admin_decision == 'create_new':
# Админ одобрил создание новой записи
model_class = get_model_class(instance.l_model_type)
model_instance = model_class(**instance.j_incoming_data)
model_instance.save(skip_validation=True)
elif instance.s_admin_decision == 'merge':
# Админ решил объединить (добавить синонимы, и т.д.)
existing_pk = instance.model_pk
incoming_data = instance.j_incoming_data
model_class = get_model_class(instance.l_model_type)
existing = model_class.objects.get(pk=existing_pk)
# Добавляем синонимы из входящих данных
existing_meta = existing.j_*_metadata or {}
existing_synonyms = existing_meta.get(KEY_SYNONYM_EN, [])
incoming_synonyms = incoming_data.get('j_*_metadata', {}).get(KEY_SYNONYM_EN, [])
# Объединяем и сохраняем
existing_meta[KEY_SYNONYM_EN] = list(set(existing_synonyms + incoming_synonyms))
existing.j_*_metadata = existing_meta
existing.save(skip_validation=True)
```
## Файлы для создания
### 1. Новое приложение `parser`
```
lpon_site/parser/
├── __init__.py
├── models.py # ValidatorQueue
├── admin.py # ValidatorQueueAdmin + actions
├── signals.py # Обработка решений админа
├── tasks.py # Celery tasks для парсинга
├── apps.py
└── migrations/
```
### 2. Изменения в existing коде
**settings.py:**
```python
INSTALLED_APPS = [
...,
'parser', # Новое приложение
]
# Конфигурация Celery (если еще нет)
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
```
**frontend/models.py:**
```python
# В методе save() моделей добавить параметр
def save(self, *args, skip_validation=False, **kwargs):
if not skip_validation:
# Стандартная валидация
validate_and_raise_for_duplicates(...)
super().save(*args, **kwargs)
```
**frontend/utils.py:**
```python
# Добавить функцию отправки в Redis
def send_to_redis_queue(queue_name: str, data: dict):
"""Отправить уведомление в Redis"""
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
r.lpush(queue_name, json.dumps({
'timestamp': datetime.now().isoformat(),
**data
}))
```
## TODO задачи
- [ ] Создать приложение `parser` с моделью `ValidatorQueue`
- [ ] Реализовать админку `ValidatorQueueAdmin` с actions
- [ ] Написать Celery tasks для парсинга Excel/CSV
- [ ] Добавить обработку решений админа через signals
- [ ] Интегрировать Redis уведомления админу
- [ ] Добавить параметр `skip_validation` в save() методы
- [ ] Написать тесты для парсера и валидации
- [ ] Документация для парсера (как использовать)
## Примечания
1. **Redis vs БД для очереди?** → Используем БД (ValidatorQueue модель) как основное хранилище, Redis только для уведомлений админу в реальном времени
2. **Async валидация?** → Парсер работает в Celery, валидация синхронна внутри задачи
3. **Миграция истории?** → Поле `t_history_created` уже `editable=True` в TbOfferHistory, так что админ может вручную добавлять исторические данные
4. **Конфликты в админке?**`ValidatorQueue` блокирует парсер от создания дубликатов. Админ сам решает что делать.
## Ссылки на существующий код
- Валидаторы: `lpon_site/frontend/utils_validators.py`
- Модели: `lpon_site/frontend/models.py` (методы save() для TbArtist, TbItem, TbLabel, TbSeller, TbMusicStyle)
- TODO про Redis: `lpon_site/frontend/utils.py` (в функции `create_or_get_related_article()`)
- TODO про историю: `lpon_site/frontend/models.py``TbOffer.save()`)
+6
View File
@@ -0,0 +1,6 @@
# В этом каталоге будут храниться файлы базы данных SQLite или другие форматы, используемые в проекте.
# Ничего не должно попадаьт в репозиторий
*.*
# Чтобы Git отслеживал пустой каталог можно использовать .gitkeep (но у нас его рель исполняет .gitignore)
!.gitkeep
@@ -2,34 +2,34 @@
## Обзор ## Обзор
Это руководство описывает, как использовать и обновлять сборку CodeMirror 6, которая используется для редактирования текстовых полей в админке Django. Это руководство описывает, как использовать и обновлять сборку CodeMirror 6, которая используется для редактирования
текстовых полей в админке Django.
## Структура проекта ## Структура проекта
- `frontend-assembly/`: Исходные файлы и скрипты для сборки. - `frontend-assembly/codemirror/`: Исходные файлы и конфиг для CodeMirror.
- `package.json`: Список зависимостей CodeMirror 6. - `package.json`: Список зависимостей CodeMirror 6.
- `package-lock.json`: Файл с зафиксированными версиями зависимостей. **Не редактировать вручную!** - `package-lock.json`: Файл с зафиксированными версиями зависимостей. **Не редактировать вручную!**
- `build-codemirror6.sh`: Скрипт, который генерирует и собирает финальный JS-бандл. - `scripts/`: Скрипты для сборки компонентов CodeMirror.
- `build-codemirror.sh`: Скрипт, который генерирует и собирает финальный JS-бандл.
- `public/static/codemirror/`: Каталог с готовыми файлами для Django. - `public/static/codemirror/`: Каталог с готовыми файлами для Django.
- `editor.js`: Финальный, минифицированный JS-бандл. **Не редактировать вручную!** - `editor.js`: Финальный, минифицированный JS-бандл. **Не редактировать вручную!**
- `codemirror-styles.css`: Кастомные стили для интеграции с админкой. - `codemirror-styles.css`: Кастомные стили для интеграции с админкой (_приготовлено отдельно, не входят
- `codemirror-patch.js`: JS-патч для управления размерами редактора. в сборку CodeMirror_).
- `codemirror-patch.js`: JS-патч для управления размерами редактора (_приготовлено отдельно, не входит
в сборку CodeMirror_).
## Как собрать бандл ## Как собрать бандл
После клонирования репозитория или обновления зависимостей, выполните: После клонирования репозитория или обновления зависимостей, выполните из корня проекта:
```bash ```bash
# Перейдите в каталог сборки bash ./scripts/build-codemirror.sh
cd frontend-assembly
# Установите зависимости и соберите бандл
bash ./build-codemirror6.sh
``` ```
## Как использовать в Django Admin ## Как использовать в Django Admin
### 1. Подключение в ModelAdmin ### 1. Стандартное подключение через Media класс
В вашем `admin.py` используйте `Media` класс для подключения необходимых файлов. В вашем `admin.py` используйте `Media` класс для подключения необходимых файлов.
@@ -47,9 +47,9 @@ class YourModelAdmin(admin.ModelAdmin):
) )
``` ```
### 2. Активация для полей ### 1.1 Активация для полей (стандартная)
Чтобы превратить стандартный `Textarea` в редактор CodeMirror, используйте кастомную форму или метод `formfield_for_dbfield` и добавьте к виджету специальные `data-` атрибуты. Чтобы превратить стандартный `Textarea` в редактор CodeMirror, используйте метод `formfield_for_dbfield` и добавьте к виджету специальные `data-` атрибуты.
```python ```python
# frontend/admin.py # frontend/admin.py
@@ -80,7 +80,37 @@ class YourModelAdmin(admin.ModelAdmin):
return formfield return formfield
``` ```
### 3. Поддерживаемые языки ### 1.2 Подключение с помощью CodeMirrorFormMixin (рекомендуется)
Для удобства используйте готовый миксин `CodeMirrorFormMixin` из `frontend/admin.py`. Он автоматически подключает Media и предоставляет helper метод для конфигурации полей.
```python
# frontend/admin.py
from frontend.admin import CodeMirrorFormMixin
class MyModelForm(CodeMirrorFormMixin):
"""Форма с поддержкой CodeMirror"""
class Meta:
model = MyModel
fields = ('field1', 'field2', 'j_metadata')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Helper метод для быстрой конфигурации поля
self.setup_codemirror_field('j_metadata', 'json', 'codemirror-width-l')
class MyModelAdmin(admin.ModelAdmin):
form = MyModelForm
# Media автоматически наследуется из MyModelForm!
```
**Преимущества миксина:**
- ✅ Media подключается автоматически
- ✅ Helper метод `setup_codemirror_field()` упрощает конфигурацию
- ✅ Используется во всех админ-формах LPON (TbOffer, TbArtist, TbLabel, и т.д.)
### 2. Поддерживаемые языки
- `javascript` - `javascript`
- `css` - `css`
@@ -104,7 +134,7 @@ class YourModelAdmin(admin.ModelAdmin):
## Как добавить новый язык (например, jinja2 или xml) ## Как добавить новый язык (например, jinja2 или xml)
1. **Добавить зависимость**: Откройте `frontend-assembly/package.json` и добавьте в `devDependencies` новый языковой пакет. Версию выбирайте близкую к другим пакетам `@codemirror/lang-*`. 1. **Добавить зависимость**: Откройте `frontend-assembly/codemirror/package.json` и добавьте в `devDependencies` новый языковой пакет. Версию выбирайте близкую к другим пакетам `@codemirror/lang-*`.
```json ```json
// package.json // package.json
@@ -115,7 +145,7 @@ class YourModelAdmin(admin.ModelAdmin):
... ...
``` ```
2. **Обновить скрипт сборки**: Откройте `frontend-assembly/build-codemirror6.sh` и внесите два изменения в секцию `cat > "$WORK_DIR/src/editor.js" <<'EOF'`: 2. **Обновить скрипт сборки**: Откройте `scripts/build-codemirror.sh` и внесите два изменения в секцию `cat > "$CODEMIRROR_DIR/src/editor.js" <<'EOF'`:
- Добавьте импорт: - Добавьте импорт:
```javascript ```javascript
import { xml } from '@codemirror/lang-xml'; import { xml } from '@codemirror/lang-xml';
@@ -129,9 +159,7 @@ class YourModelAdmin(admin.ModelAdmin):
3. **Пересобрать бандл**: 3. **Пересобрать бандл**:
```bash ```bash
cd frontend-assembly bash ./scripts/build-codemirror.sh
npm install # Установит новый пакет и обновит package-lock.json
bash build-codemirror6.sh # Пересоберет editor.js с поддержкой XML
``` ```
4. **Использовать в админке**: Теперь вы можете использовать `data-language="xml"` для любого поля. 4. **Использовать в админке**: Теперь вы можете использовать `data-language="xml"` для любого поля.
@@ -140,13 +168,15 @@ class YourModelAdmin(admin.ModelAdmin):
1. **Проверить устаревшие пакеты**: 1. **Проверить устаревшие пакеты**:
```bash ```bash
cd frontend-assembly cd frontend-assembly/codemirror
npm outdated npm outdated
``` ```
2. **Обновить версии**: Аккуратно обновите номера версий в `package.json`. 2. **Обновить версии**: Аккуратно обновите номера версий в `frontend-assembly/codemirror/package.json`.
3. **Установить обновления и пересобрать**: 3. **Установить обновления и пересобрать**:
```bash ```bash
cd frontend-assembly/codemirror
npm install npm install
bash build-codemirror6.sh cd ../../
bash ./scripts/build-codemirror.sh
``` ```
4. **Закоммитить изменения**: Добавьте в коммит обновленные `package.json`, `package-lock.json` и `public/static/codemirror/editor.js`. 4. **Закоммитить изменения**: Добавьте в коммит обновленные `frontend-assembly/codemirror/package.json`, `frontend-assembly/codemirror/package-lock.json` и `public/static/codemirror/editor.js`.
+527
View File
@@ -0,0 +1,527 @@
{
"name": "lpon-alpine",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lpon-alpine",
"version": "1.0.0",
"devDependencies": {
"alpinejs": "^3.14.3",
"esbuild": "^0.28.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"dev": true,
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "lpon-alpine",
"version": "1.0.0",
"description": "Alpine.js build for LPON.RU Django Frontend",
"private": true,
"scripts": {
"build": "esbuild src/alpine.js --bundle --minify --outfile=../../public/static/js/alpine.min.js"
},
"devDependencies": {
"alpinejs": "^3.14.3",
"esbuild": "^0.28.0"
}
}
@@ -5,7 +5,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "esbuild src/editor.js --bundle --format=iife --minify --outfile=${CM6_OUTPUT_DIR:-../public/static/codemirror}/editor.js" "build": "esbuild src/editor.js --bundle --format=iife --minify --outfile=${CM6_OUTPUT_DIR:-../../public/static/codemirror}/editor.js"
}, },
"devDependencies": { "devDependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.29.7",
+507
View File
@@ -0,0 +1,507 @@
{
"name": "lpon-htmx",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lpon-htmx",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.28.0",
"htmx.org": "^1.9.12"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/htmx.org": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"dev": true,
"license": "0BSD"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "lpon-htmx",
"version": "1.0.0",
"description": "HTMX build for LPON.RU Django Frontend",
"private": true,
"scripts": {
"build": "esbuild src/htmx.js --bundle --minify --outfile=../../public/static/js/htmx.min.js"
},
"devDependencies": {
"htmx.org": "^1.9.12",
"esbuild": "^0.28.0"
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "lpon-tailwind",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "npx tailwindcss -i input.css -o ../../public/static/css/tailwind.min.css --minify"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}
+1013 -320
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
from django.conf import settings
from django.http import HttpRequest
# Имя куки, которая ставится клиенту, когда он СОГЛАСИЛСЯ на отслеживание
# (Google Tag Manager, Яндекс.Метрика и т.п.). Если кука присутствует —
# предупреждение о трекинге больше не показываем.
ALLOW_TRACKING_COOKIE_NAME = "allow_tracking"
def site_context(request: HttpRequest) -> dict:
"""
Общий (универсальный) контекст-процессор Django для всего проекта.
ЗАЧЕМ ЭТО НУЖНО:
Автоматически добавляет во ВСЕ шаблоны проекта переменные, которые
нужны почти на каждой странице, без необходимости передавать их
вручную в контексте каждой вьюхи. Контекст-процессор необходимо
зарегистрировать в settings.py: TEMPLATES['OPTIONS']['context_processors']
ПОЧЕМУ ОДИН УНИВЕРСАЛЬНЫЙ ПРОЦЕССОР, А НЕ НЕСКОЛЬКО РАЗНЫХ:
И DEBUG-флаг, и флаг согласия на трекинг — это одинаковые по сути
"сквозные" данные: маленькие, не завязанные на конкретную вьюху,
нужные почти во всех шаблонах (в _base.html). Заводить под каждую
такую мелочь отдельный контекст-процессор — это лишний файл и лишняя
строка регистрации в settings.py на ровном месте. Если в будущем
появится много разнородных "сквозных" переменных — тогда, возможно,
стоит разнести их по смыслу (например, отдельно cookies-consent,
отдельно debug/env), но пока их всего две — держим вместе.
ПОЧЕМУ НЕ штатный django.template.context_processors.debug (для IS_DEBUG):
Штатный context-processor отдаёт переменную `debug` только если
ОДНОВРЕМЕННО: settings.DEBUG == True И IP клиента входит в
settings.INTERNAL_IPS (сверяется по request.META['REMOTE_ADDR']).
В проекте с Docker/reverse-proxy — REMOTE_ADDR может быть не 127.0.0.1,
а IP шлюза docker-сети, и потому штатный контекстный-процессор ненадёжен.
ПРО ALLOW_TRACKING:
Читаем куку request.COOKIES.get(ALLOW_TRACKING_COOKIE_NAME) — её
ставит клиентский JS (или сервер) ПОСЛЕ того, как пользователь
согласился с предупреждением об отслеживании (Google Tag Manager,
Яндекс.Метрика и т.п.). Если кука присутствует (независимо от её
значения — само наличие куки уже означает согласие) — предупреждение
в шаблоне больше не показываем, а также можно подключать сами
счётчики трекинга ({% if ALLOW_TRACKING %}...{% endif %}).
:return
dict: словарь с ключами:
IS_DEBUG (bool) — режим отладки Django (settings.DEBUG);
ALLOW_TRACKING (bool) — согласился ли клиент на отслеживание
(определяется по наличию куки "allow_tracking").
Обе переменные доступны во всех шаблонах:
{% if IS_DEBUG %}...{% endif %}
{% if ALLOW_TRACKING %}...{% endif %}
"""
return {
"IS_DEBUG": settings.DEBUG,
"ALLOW_TRACKING": ALLOW_TRACKING_COOKIE_NAME in request.COOKIES,
}
@@ -0,0 +1,366 @@
"""
Django management команда для перегенерации и проверки s_offer_code офферов.
ВАЖНО: При смене OFFER_HASHIDS_SALT нужно обновить ВСЕ коды сразу!
Иначе старые коды перестанут работать и QR-коды станут невалидными.
Примеры использования:
# 1. Проверить все коды (декодирование и валидация)
python manage.py regenerate_offer_codes --check
# 2. Проверить и показать некорректные коды
python manage.py regenerate_offer_codes --check --verbose
# 3. Обновить только некорректные коды
python manage.py regenerate_offer_codes --fix-broken
# 4. Обновить ВСЕ коды (при смене соли)
python manage.py regenerate_offer_codes
# 5. Пробный запуск (ничего не сохранять)
python manage.py regenerate_offer_codes --dry-run
"""
from django.core.management.base import BaseCommand, CommandError
from hashids import Hashids
from lpon_site.settings import OFFER_HASHIDS_SALT, OFFER_HASHIDS_MIN_LENGTH
from frontend.models import TbOffer
class Command(BaseCommand):
"""
Перегенерирует, проверяет и восстанавливает s_offer_code офферов.
"""
help = 'Перегенерирует/проверяет s_offer_code для офферов на основе текущего OFFER_HASHIDS_SALT'
def add_arguments(self, parser):
"""Добавляем опциональные аргументы"""
parser.add_argument(
'--check',
action='store_true',
help='Проверить корректность всех кодов (декодировать обратно в id)',
)
parser.add_argument(
'--fix-broken',
action='store_true',
help='Обновить только коды, которые не декодируются правильно',
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Показать, что будет изменено, но не сохранять',
)
parser.add_argument(
'--verbose',
action='store_true',
help='Показывать подробный прогресс для каждого оффера',
)
def handle(self, *args, **options):
"""Основная логика команды"""
check_mode = options['check']
fix_broken = options['fix_broken']
dry_run = options['dry_run']
verbose = options['verbose']
# Инициализируем Hashids
hashids = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH)
# Получаем все офферы
queryset = TbOffer.objects.all()
total_count = queryset.count()
if total_count == 0:
self.stdout.write(self.style.WARNING('Нет офферов для обработки'))
return
self.stdout.write(
self.style.SUCCESS(f'Найдено офферов: {total_count}')
)
# ===== РЕЖИМ 1: ПРОВЕРКА КОДОВ =====
if check_mode:
self._check_codes(queryset, hashids, verbose, total_count)
return
# ===== РЕЖИМ 2: ОБНОВЛЕНИЕ ТОЛЬКО НЕКОРРЕКТНЫХ =====
if fix_broken:
self._fix_broken_codes(queryset, hashids, dry_run, verbose, total_count)
return
# ===== РЕЖИМ 3: ОБНОВЛЕНИЕ ВСЕХ КОДОВ =====
self._regenerate_all_codes(queryset, hashids, dry_run, verbose, total_count)
def _check_codes(self, queryset, hashids, verbose, total_count):
"""
Режим проверки: декодируем все коды обратно и проверяем, что получается исходный id.
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('РЕЖИМ ПРОВЕРКИ КОДОВ'))
self.stdout.write('=' * 70)
broken_count = 0
valid_count = 0
broken_offers = []
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Пытаемся декодировать код обратно в ID
decoded_ids = hashids.decode(offer.s_offer_code)
if not decoded_ids or decoded_ids[0] != offer.id:
# Код не декодируется или декодируется в неправильный ID
broken_count += 1
broken_offers.append({
'id': offer.id,
'code': offer.s_offer_code,
'decoded': decoded_ids[0] if decoded_ids else None,
'reason': 'Неверный ID' if decoded_ids else 'Не декодируется'
})
if verbose:
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" '
f'декодируется в {decoded_ids[0] if decoded_ids else "ОШИБКА"}'
)
)
else:
# Код корректный
valid_count += 1
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id} [ok!] код "{offer.s_offer_code}"'
)
except Exception as e:
broken_count += 1
broken_offers.append({
'id': offer.id,
'code': offer.s_offer_code,
'error': str(e)
})
if verbose:
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Проверено: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ПРОВЕРКИ'))
self.stdout.write('=' * 70)
self.stdout.write(self.style.SUCCESS(f'[ok!] Корректных кодов: {valid_count}'))
if broken_count > 0:
self.stdout.write(self.style.ERROR(f'[err] Некорректных кодов: {broken_count}'))
if broken_count <= 20:
# Показываем все если мало
self.stdout.write('\nДетали некорректных кодов:')
for item in broken_offers:
if 'error' in item:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', ошибка: {item['error']}"
)
)
else:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', "
f"декодируется в {item['decoded']}, причина: {item['reason']}"
)
)
else:
self.stdout.write(f'\nПервые 20 некорректных кодов:')
for item in broken_offers[:20]:
if 'error' in item:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', ошибка: {item['error']}"
)
)
else:
self.stdout.write(
self.style.ERROR(
f" ID={item['id']}, код='{item['code']}', "
f"декодируется в {item['decoded']}, причина: {item['reason']}"
)
)
else:
self.stdout.write(self.style.SUCCESS('Все коды корректны! [ok!]'))
self.stdout.write(f'\nОбщее количество: {valid_count + broken_count}')
self.stdout.write(
self.style.WARNING(
'\nДля обновления некорректных кодов используйте: '
'--fix-broken'
)
)
def _fix_broken_codes(self, queryset, hashids, dry_run, verbose, total_count):
"""
Режим исправления: обновляем только те коды которые не декодируются правильно.
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('РЕЖИМ ИСПРАВЛЕНИЯ НЕКОРРЕКТНЫХ КОДОВ'))
self.stdout.write('=' * 70)
updated = 0
skipped = 0
failed = 0
if dry_run:
self.stdout.write(self.style.WARNING('!! DRY-RUN: изменения НЕ будут сохранены !!'))
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Проверяем корректность текущего кода
decoded_ids = hashids.decode(offer.s_offer_code)
if decoded_ids and decoded_ids[0] == offer.id:
# Код корректный - пропускаем
skipped += 1
if verbose:
self.stdout.write(f' [{idx}/{total_count}] ID={offer.id} [ok!] пропущен (корректный)')
continue
# Код некорректный - обновляем
new_code = hashids.encode(offer.id)
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id}: '
f'"{offer.s_offer_code}""{new_code}"'
)
if not dry_run:
# Обновляем напрямую (без вызова save() чтобы не было валидации)
TbOffer.objects.filter(id=offer.id).update(s_offer_code=new_code)
updated += 1
except Exception as e:
failed += 1
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Обработано: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ИСПРАВЛЕНИЯ'))
self.stdout.write('=' * 70)
self.stdout.write(f'Пропущено (корректные): {skipped}')
if not dry_run:
self.stdout.write(self.style.SUCCESS(f'Обновлено: {updated}'))
else:
self.stdout.write(self.style.WARNING(f'Было бы обновлено: {updated} (dry-run)'))
if failed > 0:
self.stdout.write(self.style.ERROR(f'Ошибок: {failed}'))
else:
self.stdout.write(self.style.SUCCESS('Ошибок: 0'))
self.stdout.write(f'\nОбщее количество обработано: {skipped + updated + failed}')
if dry_run:
self.stdout.write(self.style.WARNING('Изменения НЕ были сохранены (dry-run)'))
def _regenerate_all_codes(self, queryset, hashids, dry_run, verbose, total_count):
"""
Режим полного обновления: перегенерируем ВСЕ коды.
ВНИМАНИЕ: Используется при смене OFFER_HASHIDS_SALT!
"""
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.ERROR('РЕЖИМ ПОЛНОГО ОБНОВЛЕНИЯ ВСЕХ КОДОВ'))
self.stdout.write('=' * 70)
self.stdout.write(
self.style.WARNING(
'ВНИМАНИЕ! Это обновит ВСЕ коды. Старые коды и QR-коды перестанут работать!\n'
'Используется только при смене OFFER_HASHIDS_SALT на сервере.'
)
)
updated = 0
unchanged = 0
failed = 0
if dry_run:
self.stdout.write(self.style.WARNING('!! DRY-RUN: изменения НЕ будут сохранены !!'))
for idx, offer in enumerate(queryset.iterator(chunk_size=1000), 1):
try:
# Генерируем новый код
new_code = hashids.encode(offer.id)
# Проверяем изменился ли
if offer.s_offer_code == new_code:
unchanged += 1
if verbose:
self.stdout.write(f' [{idx}/{total_count}] ID={offer.id}: без изменений')
else:
if verbose:
self.stdout.write(
f' [{idx}/{total_count}] ID={offer.id}: '
f'"{offer.s_offer_code}""{new_code}"'
)
if not dry_run:
# Обновляем напрямую в БД
TbOffer.objects.filter(id=offer.id).update(s_offer_code=new_code)
updated += 1
except Exception as e:
failed += 1
self.stdout.write(
self.style.ERROR(
f' [{idx}/{total_count}] ID={offer.id} [err] код "{offer.s_offer_code}" ОШИБКА: {str(e)}'
)
)
# Показываем прогресс каждые 1000
if idx % 1000 == 0:
self.stdout.write(f'Обработано: {idx}/{total_count}')
# Отчет
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('ИТОГОВЫЙ ОТЧЕТ ПОЛНОГО ОБНОВЛЕНИЯ'))
self.stdout.write('=' * 70)
self.stdout.write(f'Без изменений: {unchanged}')
if not dry_run:
self.stdout.write(self.style.SUCCESS(f'Обновлено: {updated}'))
else:
self.stdout.write(self.style.WARNING(f'Было бы обновлено: {updated} (dry-run)'))
if failed > 0:
self.stdout.write(self.style.ERROR(f'Ошибок: {failed}'))
else:
self.stdout.write(self.style.SUCCESS('Ошибок: 0'))
# Параметры
self.stdout.write(f'\nПараметры Hashids:')
self.stdout.write(f' Соль: {OFFER_HASHIDS_SALT[:16]}...')
self.stdout.write(f' Минимальная длина: {OFFER_HASHIDS_MIN_LENGTH}')
self.stdout.write(f'\nОбщее количество обработано: {unchanged + updated + failed}')
if dry_run:
self.stdout.write(self.style.WARNING('Изменения НЕ были сохранены (dry-run)'))
else:
self.stdout.write(self.style.SUCCESS('Команда завершена успешно! ✓'))
+51 -68
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.5 on 2026-06-04 12:31 # Generated by Django 6.0.5 on 2026-07-01 15:16
import datetime import datetime
import django.db.models.deletion import django.db.models.deletion
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('filer', '0018_alter_file_options'), ('filer', '0019_alter_clipboard_id_alter_clipboarditem_id_and_more'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
] ]
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('s_article_title', models.CharField(default='', help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road" или "Bio: The Beatles".', max_length=255, unique=True, verbose_name='Технический заголовок')), ('s_article_title', models.CharField(default='', help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road" или "Bio: The Beatles".', max_length=255, unique=True, verbose_name='Технический заголовок')),
('l_article_type', models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')), ('l_article_type', models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('label', 'Label: Лейбл, издатель или компания'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')),
('b_article_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубликовано')), ('b_article_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубликовано')),
('t_article_started', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата начала публикации')), ('t_article_started', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата начала публикации')),
('t_article_ended', models.DateTimeField(blank=True, db_index=True, default=None, help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации. Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена или снята с публикации через `b_article_published`)', null=True, verbose_name='Дата окончания публикации')), ('t_article_ended', models.DateTimeField(blank=True, db_index=True, default=None, help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации. Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена или снята с публикации через `b_article_published`)', null=True, verbose_name='Дата окончания публикации')),
@@ -45,41 +45,12 @@ class Migration(migrations.Migration):
'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'), 'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'),
}, },
), ),
migrations.CreateModel(
name='TbFormat',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_format', models.CharField(db_index=True, help_text='Название формата носителя, например: "LP", "CD", "Blu-ray", "Compact Cassette", "MiniDisc", "Hi-Fi", "Accessory" и т.д.', max_length=16, unique=True, verbose_name='Формат носителя')),
('s_format_slug', models.SlugField(max_length=16, unique=True, verbose_name='Слаг')),
],
options={
'verbose_name': 'Формат носителя',
'verbose_name_plural': 'Форматы носителей',
'ordering': ('s_format',),
},
),
migrations.CreateModel(
name='TbMusicStyle',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_style_name', models.CharField(db_index=True, help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"', max_length=100, unique=True, verbose_name='Стиль (канонический)')),
('s_style_slug', models.SlugField(editable=False, help_text='Автоматически генерируется из названия. Используется в URL и API.', unique=True, verbose_name='Слаг (уникальный идентификатор)')),
('j_style_synonyms', models.JSONField(blank=True, default=list, help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]', verbose_name='Синонимы из источников')),
('t_style_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_style_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Музыкальный стиль',
'verbose_name_plural': 'Музыкальные стили',
'ordering': ('s_style_name',),
},
),
migrations.CreateModel( migrations.CreateModel(
name='TbArtist', name='TbArtist',
fields=[ fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)), ('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_artist', models.CharField(help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или"David Bowie".', max_length=128, unique=True, verbose_name='Исполнитель')), ('s_artist', models.CharField(help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или"David Bowie".', max_length=128, unique=True, verbose_name='Исполнитель')),
('j_artist_metadata', models.JSONField(blank=True, default=list, help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles", "Beatles, The"]', null=True, verbose_name='Метаданные JSON')), ('j_artist_metadata', models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYNONYM": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON')),
('t_artist_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('t_artist_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_artist_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('t_artist_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_artist_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья')), ('k_artist_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья')),
@@ -96,13 +67,12 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('l_img_source', models.CharField(choices=[('parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'), ('manual', 'Ручная загрузка пользователем'), ('vendor', 'От продавца'), ('other', 'Другое')], default='manual', help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например, Discogs), предоставлен продавцом и т.д.', max_length=10, verbose_name='Источник')), ('l_img_source', models.CharField(choices=[('parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'), ('manual', 'Ручная загрузка пользователем'), ('vendor', 'От продавца'), ('other', 'Другое')], default='manual', help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например, Discogs), предоставлен продавцом и т.д.', max_length=10, verbose_name='Источник')),
('l_img_reality', models.CharField(choices=[('real', 'Реальная фотография товара'), ('abstract', 'Абстрактное (из внешнего источника)')], default='abstract', help_text='Реальная фотография товара или картинка из внешнего источника?', max_length=10, verbose_name='Тип снимка')), ('l_img_reality', models.CharField(choices=[('real', 'Реальная фотография товара'), ('abstract', 'Абстрактное (из внешнего источника)')], default='abstract', help_text='Реальная фотография товара или картинка из внешнего источника?', max_length=10, verbose_name='Тип снимка')),
('s_img_src_url', models.URLField(blank=True, help_text='Если изображение взято из внешнего источника (например, Discogs)', null=True, verbose_name='URL источника')), ('s_img_src_url', models.URLField(blank=True, help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL')),
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Cортировка')), ('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка')),
('f_img_confidence_score', models.FloatField(blank=True, default=None, help_text='0.0 - 1.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Уверенность (для автоматических данных)')), ('f_img_confidence_score', models.FloatField(blank=True, default=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность')),
('s_img_copyright', models.CharField(blank=True, default='', help_text='Например: "© 2024 User" или "CC-BY"', max_length=255, verbose_name='Авторские права / Лицензия')),
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')), ('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('image', filer.fields.image.FilerImageField(blank=True, help_text='Файл изображения, загруженный через django_filer.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')), ('image', filer.fields.image.FilerImageField(help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
], ],
options={ options={
'verbose_name': 'Изображение', 'verbose_name': 'Изображение',
@@ -115,32 +85,12 @@ class Migration(migrations.Migration):
name='k_article_to_image', name='k_article_to_image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to='frontend.tbimage', verbose_name='Изображение для статьи'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to='frontend.tbimage', verbose_name='Изображение для статьи'),
), ),
migrations.CreateModel(
name='TbItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('s_item', models.CharField(help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".', max_length=128, unique=True, verbose_name='Товар')),
('s_item_date', models.CharField(blank=True, default='XXXX-XX-XX', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию. Срабатывает только если t_release_date не указано.', max_length=10, null=True, verbose_name='Дата релиза (str)')),
('t_item_date', models.DateField(blank=True, help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', null=True, verbose_name='Дата релиза')),
('i_discogs_master_id', models.IntegerField(blank=True, default=None, help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: <tt>306323</tt>', null=True, verbose_name='ID на мастер-релиз Discogs')),
('j_item_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', null=True, verbose_name='Дополнительные данные')),
('t_item_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_item_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_item_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья')),
('k_item_to_artist', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько для коллабораций', related_name='artist_to_item', to='frontend.tbartist', verbose_name='Исполнители')),
],
options={
'verbose_name': 'Товар в каталоге (релиз, носитель, аксессуар)',
'verbose_name_plural': 'Товары в каталоге',
'ordering': ('s_item',),
},
),
migrations.CreateModel( migrations.CreateModel(
name='TbLabel', name='TbLabel',
fields=[ fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)), ('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_label', models.CharField(help_text='Техническое название лейбла. Например: "Sony Records" или "Мелодия"', max_length=128, unique=True, verbose_name='Лейбл')), ('s_label', models.CharField(help_text='Название лейбла. Например: "Sony Records" или "Мелодия"... Представление на самом сайте, с версткой, будет определиться через статью, связанную с лейблом (TbArticle).', max_length=128, unique=True, verbose_name='Лейбл')),
('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д.', null=True, verbose_name='Метаданные')), ('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <tt>{"SYNONYM": ["Island", "Island Records", "Vertigo France"]}</tt>.', null=True, verbose_name='Метаданные')),
('t_label_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('t_label_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_label_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('t_label_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_label_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья')), ('k_label_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья')),
@@ -151,18 +101,52 @@ class Migration(migrations.Migration):
'ordering': ('s_label',), 'ordering': ('s_label',),
}, },
), ),
migrations.AddField( migrations.CreateModel(
model_name='tbarticle', name='TbMusicStyle',
name='k_article_to_styles', fields=[
field=models.ManyToManyField(blank=True, db_index=True, help_text='Стили этой статьи/артиста/релиза (Rock, Jazz, Classical, ...)', related_name='style_to_article', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили'), ('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_style_name', models.CharField(db_index=True, help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"', max_length=100, unique=True, verbose_name='Стиль (канонический)')),
('j_style_metadata', models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYNONYM": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.', verbose_name='Метаданные')),
('t_style_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_style_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_style_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья о музыкальном стиле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_style', to='frontend.tbarticle', verbose_name='Связанная статья')),
],
options={
'verbose_name': 'Музыкальный стиль',
'verbose_name_plural': 'Музыкальные стили',
'ordering': ('s_style_name',),
},
),
migrations.CreateModel(
name='TbItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('s_item', models.CharField(help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".', max_length=128, unique=True, verbose_name='Товар')),
('s_item_date', models.CharField(blank=True, db_index=True, default='XXXX-XX-XX', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию. Срабатывает только если t_release_date не указано.', max_length=10, null=True, verbose_name='Дата релиза (str)')),
('t_item_date', models.DateField(blank=True, db_index=True, help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', null=True, verbose_name='Дата релиза')),
('i_discogs_master_id', models.IntegerField(blank=True, default=None, help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: <tt>306323</tt>', null=True, verbose_name='ID на мастер-релиз Discogs')),
('j_item_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', null=True, verbose_name='Дополнительные данные')),
('t_item_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_item_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_item_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья')),
('k_item_to_artist', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько для коллабораций', related_name='artist_to_item', to='frontend.tbartist', verbose_name='Исполнители')),
('k_item_to_style', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.', related_name='style_to_item', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили')),
],
options={
'verbose_name': 'Товар в каталоге (релиз, носитель, аксессуар)',
'verbose_name_plural': 'Товары в каталоге',
'ordering': ('s_item',),
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='TbOffer', name='TbOffer',
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('s_offer', models.CharField(db_index=True, help_text='Техническое название оффера для внутреннего использования, например: "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"', max_length=128, verbose_name='Название оффера')), ('s_offer', models.CharField(db_index=True, help_text='Техническое название оффера для внутреннего использования, например: "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"', max_length=128, verbose_name='Название оффера')),
('l_offer_to_format', models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"formats": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат')),
('s_offer_catalog_num', models.TextField(blank=True, default='', help_text='Например: "SD 16023" или "5099923452355"', verbose_name='Каталожный номер / Barcode')), ('s_offer_catalog_num', models.TextField(blank=True, default='', help_text='Например: "SD 16023" или "5099923452355"', verbose_name='Каталожный номер / Barcode')),
('d_offer_date_release', models.DateField(blank=True, default=None, help_text='Дата релиза, если она известна (например, дата выпуска переиздания)', null=True, verbose_name='Дата релиза')), ('b_offer_is_preorder', models.BooleanField(db_index=True, default=False, help_text='Если товар доступен только для предзаказа', verbose_name='Предзаказ')),
('d_offer_date_release', models.DateField(db_index=True, default=None, help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', null=True, verbose_name='Дата релиза')),
('i_offer_discogs_id', models.IntegerField(blank=True, default=0, help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>', verbose_name='ID на релиз Discogs')), ('i_offer_discogs_id', models.IntegerField(blank=True, default=0, help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>', verbose_name='ID на релиз Discogs')),
('l_offer_condition_media', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние носителя')), ('l_offer_condition_media', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние носителя')),
('l_offer_condition_sleeve', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние обложки')), ('l_offer_condition_sleeve', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние обложки')),
@@ -176,7 +160,6 @@ class Migration(migrations.Migration):
('t_offer_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('t_offer_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_offer_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('t_offer_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_offer_to_article', models.ForeignKey(blank=True, default=None, help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>МОЖНО НЕ УКАЗЫВАТЬ</b> т.к. URL оффера (для корзины) формируется через id или хеш.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_offer', to='frontend.tbarticle', verbose_name='Связанная статья')), ('k_offer_to_article', models.ForeignKey(blank=True, default=None, help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>МОЖНО НЕ УКАЗЫВАТЬ</b> т.к. URL оффера (для корзины) формируется через id или хеш.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_offer', to='frontend.tbarticle', verbose_name='Связанная статья')),
('k_offer_to_format', models.ManyToManyField(blank=True, db_index=True, help_text='Форматы носителей (пластинка, CD, кассета и т.п.). Можно выбрать несколько.', related_name='format_to_offer', to='frontend.tbformat', verbose_name='Форматы')),
('k_offer_to_image', models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара. Порядок определяется полем i_img_sort в катинке.', related_name='image_to_offer', to='frontend.tbimage', verbose_name='Изображения')), ('k_offer_to_image', models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара. Порядок определяется полем i_img_sort в катинке.', related_name='image_to_offer', to='frontend.tbimage', verbose_name='Изображения')),
('k_offer_to_item', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='item_to_offer', to='frontend.tbitem', verbose_name='Релиз (товар)')), ('k_offer_to_item', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='item_to_offer', to='frontend.tbitem', verbose_name='Релиз (товар)')),
('k_offer_to_label', models.ForeignKey(default=None, help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл')), ('k_offer_to_label', models.ForeignKey(default=None, help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл')),
@@ -208,6 +191,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)), ('id', models.SmallAutoField(primary_key=True, serialize=False)),
('s_seller', models.CharField(help_text='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать с названием продавца, если лейбл сам реализует свои издания через сайт.', max_length=128, unique=True, verbose_name='Название продавца')), ('s_seller', models.CharField(help_text='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать с названием продавца, если лейбл сам реализует свои издания через сайт.', max_length=128, unique=True, verbose_name='Название продавца')),
('l_seller_currency', models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('amd', 'AMD: армянских драм'), ('try', 'TRY: турецкая лира'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника')),
('l_seller_type', models.CharField(choices=[('seller', 'Продавец'), ('label', 'Лейбл (издатель)'), ('diy', 'Самиздат группы'), ('crowd', 'Краудфандинг'), ('???', 'Другое')], default='seller', max_length=6, verbose_name='Тип продавца')), ('l_seller_type', models.CharField(choices=[('seller', 'Продавец'), ('label', 'Лейбл (издатель)'), ('diy', 'Самиздат группы'), ('crowd', 'Краудфандинг'), ('???', 'Другое')], default='seller', max_length=6, verbose_name='Тип продавца')),
('j_seller_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.', null=True, verbose_name='Дополнительные данные')), ('j_seller_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.', null=True, verbose_name='Дополнительные данные')),
('t_seller_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('t_seller_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
@@ -224,9 +208,8 @@ class Migration(migrations.Migration):
name='TbSource', name='TbSource',
fields=[ fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)), ('id', models.SmallAutoField(primary_key=True, serialize=False)),
('l_source_currency', models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('try', 'TRY: турецкая лира'), ('amd', 'AMD: армянский драм'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника')),
('s_source_name', models.CharField(blank=True, default='', help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>', max_length=128, verbose_name='Название источника')), ('s_source_name', models.CharField(blank=True, default='', help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>', max_length=128, verbose_name='Название источника')),
('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')), ('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое (включая ручной ввод)')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')),
('t_source_data', models.DateField(blank=True, default=datetime.date.today, help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', verbose_name='Дата данных')), ('t_source_data', models.DateField(blank=True, default=datetime.date.today, help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', verbose_name='Дата данных')),
('s_source_url', models.TextField(blank=True, default='', help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.', max_length=255, verbose_name='URL источника')), ('s_source_url', models.TextField(blank=True, default='', help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.', max_length=255, verbose_name='URL источника')),
('j_source_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла, структуре HTML-страницы и т.п.) в виде JSON-словаря', verbose_name='Дополнительные данные')), ('j_source_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла, структуре HTML-страницы и т.п.) в виде JSON-словаря', verbose_name='Дополнительные данные')),
@@ -0,0 +1,85 @@
# Generated by Django 6.0.5 on 2026-07-10 07:47
import django.db.models.deletion
import filer.fields.image
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0001_initial'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.AlterField(
model_name='tbarticle',
name='k_article_to_image',
field=models.ForeignKey(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.</br><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
),
migrations.AlterField(
model_name='tbartist',
name='j_artist_metadata',
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON'),
),
migrations.AlterField(
model_name='tbartist',
name='k_artist_to_article',
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья'),
),
migrations.AlterField(
model_name='tbitem',
name='k_item_to_article',
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья'),
),
migrations.AlterField(
model_name='tblabel',
name='j_label_metadata',
field=models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <tt>{"SYN_EN": ["Island", "Island Records", "Vertigo France"]}</tt>.', null=True, verbose_name='Метаданные'),
),
migrations.AlterField(
model_name='tblabel',
name='k_label_to_article',
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья'),
),
migrations.AlterField(
model_name='tbmusicstyle',
name='j_style_metadata',
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.', verbose_name='Метаданные'),
),
migrations.AlterField(
model_name='tboffer',
name='k_offer_to_image',
field=models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара/предложения. Порядок определяется полем i_img_sort в TbImageMetadata. Доступ к метаданным: image.metadata.i_img_sort и image.metadata.j_img_metadata', related_name='offer_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображения'),
),
migrations.AlterField(
model_name='tbseller',
name='k_seller_to_article',
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_seller', to='frontend.tbarticle', verbose_name='Связанная статья'),
),
migrations.CreateModel(
name='TbImageMetadata',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка')),
('j_img_metadata', models.JSONField(blank=True, default=dict, help_text='JSON с дополнительными данными: источник (парсер, админка), тип (реальное/абстрактное), достоверность, URL источника и т.д. Гибкое хранилище, позволяет добавлять новые поля без миграций БД.', null=True, verbose_name='Метаданные')),
('l_img_source', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Источник')),
('l_img_reality', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Тип снимка')),
('s_img_src_url', models.URLField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] URL')),
('f_img_confidence_score', models.FloatField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] Достоверность')),
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='[DEPRECATED] Дата добавления')),
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='[DEPRECATED] Дата обновления')),
('image', filer.fields.image.FilerImageField(help_text='Файл изображения из django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, related_name='metadata', to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
],
options={
'verbose_name': 'Метаданные изображения',
'verbose_name_plural': 'Метаданные изображений',
'ordering': ('i_img_sort',),
},
),
migrations.DeleteModel(
name='TbImage',
),
]
@@ -1,54 +0,0 @@
# Generated by Django 6.0.5 on 2026-06-12 10:39
import django.db.models.deletion
import filer.fields.image
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0001_initial'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.RemoveField(
model_name='tboffer',
name='k_offer_to_format',
),
migrations.RemoveField(
model_name='tbimage',
name='s_img_copyright',
),
migrations.AddField(
model_name='tboffer',
name='l_offer_to_format',
field=models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"formats": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат'),
),
migrations.AlterField(
model_name='tbimage',
name='f_img_confidence_score',
field=models.FloatField(blank=True, default=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность'),
),
migrations.AlterField(
model_name='tbimage',
name='i_img_sort',
field=models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка'),
),
migrations.AlterField(
model_name='tbimage',
name='image',
field=filer.fields.image.FilerImageField(default=1, help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения'),
preserve_default=False,
),
migrations.AlterField(
model_name='tbimage',
name='s_img_src_url',
field=models.URLField(blank=True, help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL'),
),
migrations.DeleteModel(
name='TbFormat',
),
]
@@ -0,0 +1,65 @@
# Generated by Django 6.0.5 on 2026-07-10 13:40
import django.db.models.deletion
import filer.fields.image
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0002_alter_tbarticle_k_article_to_image_and_more'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.RemoveField(
model_name='tbimagemetadata',
name='f_img_confidence_score',
),
migrations.RemoveField(
model_name='tbimagemetadata',
name='l_img_reality',
),
migrations.RemoveField(
model_name='tbimagemetadata',
name='l_img_source',
),
migrations.RemoveField(
model_name='tbimagemetadata',
name='s_img_src_url',
),
migrations.RemoveField(
model_name='tbimagemetadata',
name='t_img_created',
),
migrations.RemoveField(
model_name='tbimagemetadata',
name='t_img_updated',
),
migrations.RemoveField(
model_name='tboffer',
name='k_offer_to_image',
),
migrations.AddField(
model_name='tbimagemetadata',
name='m_offer',
field=models.ForeignKey(blank=True, help_text='Оффер, к которому привязана эта картинка (может быть пусто, если оффер удален)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m_image', to='frontend.tboffer', verbose_name='Оффер'),
),
migrations.AddField(
model_name='tboffer',
name='m_offer_to_image',
field=models.ManyToManyField(blank=True, help_text='Картинки этого предложения (обложка, задник, фото и т.д). Управляются в админке TbImageMetadata с сортировкой (i_img_sort) и метаданными (j_img_metadata). Доступ в коде: offer.m_image.all() (все TbImageMetadata, отсортированные) или offer.m_offer_to_image.all() (все картинки). Каждая запись TbImageMetadata содержит: image, m_offer, i_img_sort, j_img_metadata.', related_name='m_image_to_offer', through='frontend.TbImageMetadata', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображения товара'),
),
migrations.AlterField(
model_name='tbarticle',
name='k_article_to_image',
field=filer.fields.image.FilerImageField(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.</br><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
),
migrations.AlterField(
model_name='tbimagemetadata',
name='image',
field=filer.fields.image.FilerImageField(help_text='Файл изображения из django_filer.', on_delete=django.db.models.deletion.CASCADE, related_name='m_offer', to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения'),
),
]
@@ -1,22 +0,0 @@
# Generated by Django 6.0.5 on 2026-06-12 12:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0002_remove_tboffer_k_offer_to_format_and_more'),
]
operations = [
migrations.RemoveField(
model_name='tbsource',
name='l_source_currency',
),
migrations.AddField(
model_name='tbseller',
name='l_seller_currency',
field=models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('amd', 'AMD: армянских драм'), ('try', 'TRY: турецкая лира'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника'),
),
]
@@ -0,0 +1,64 @@
# Generated by Django 6.0.5 on 2026-07-15 14:00
import django.db.models.deletion
import django.utils.timezone
import filer.fields.image
import frontend.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0003_remove_tbimagemetadata_f_img_confidence_score_and_more'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.AlterField(
model_name='tbarticle',
name='k_article_to_image',
field=filer.fields.image.FilerImageField(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.<br/><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
),
migrations.AlterField(
model_name='tbimagemetadata',
name='j_img_metadata',
field=models.JSONField(blank=True, default=frontend.models.get_image_metadata_default, help_text='JSON с дополнительными данными: тип (реальное/абстрактное), источник (парсер/админка), URL источника и заметки. Предзаполнено структурой для удобства.', verbose_name='Метаданные'),
),
migrations.AlterField(
model_name='tboffer',
name='d_offer_date_release',
field=models.DateField(blank=True, db_index=True, default=None, help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', null=True, verbose_name='Дата релиза'),
),
migrations.AlterField(
model_name='tboffer',
name='i_offer_discount_to_daily_sale',
field=models.IntegerField(blank=True, db_index=True, default=0, help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано <tt>0</tt> то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', verbose_name='Возможна cкидка (%)'),
),
migrations.AlterField(
model_name='tboffer',
name='j_offer_metadata',
field=models.JSONField(default=frontend.models.get_offer_metadata_default, help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:<pre style="background-color:#80808080;border:1px solid #ccc; padding:1ex;max-width: calc(75% - 13em);">{\n "OFR_NT": "Уценка: замят угол конверта, повреждены наклейки.",\n "OFR_MF": {\n "lp": 2,\n "cd": 1\n }\n}</pre>Ключи:<ul> <li>"OFR_NT" — Примечание к офферу</li> <li>"OFR_MF" — Какие носители входят в коммерческое предложение.<br/> Допустимые значения: "lp" — Vinyl Long-Play (12"), "ep" — Vinyl Extended-Play (12", 10", 7"), "45" — Vinyl 7" (45 rpm), "cd" — Compact Disc, "ld" — LaserDisc, "md" — MiniDisc Record, "ms" — Used MiniDisc (для записи), "cs" — Cassette Record, "uc" — Used Cassette (для записи), "tp" — Tape Reel Record, "ur" — Used Tape Reel (для записи), "??" — Other</li></ul>', null=True, verbose_name='Дополнительные данные'),
),
migrations.AlterField(
model_name='tboffer',
name='k_offer_to_label',
field=models.ForeignKey(blank=True, default=None, help_text='Лейбл, на котором был выпущен релиз (если известен), или производитель для аудиио-кассет и MD под запись, технику или аксессуар.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл/Производитель'),
),
migrations.AlterField(
model_name='tboffer',
name='l_offer_to_format',
field=models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"OFR_MF": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат'),
),
migrations.AlterField(
model_name='tboffer',
name='s_offer_code',
field=models.CharField(help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id). Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера. <b>Не редактировать вручную!</b>', max_length=12, unique=True, verbose_name='Код товара'),
),
migrations.AlterField(
model_name='tbofferhistory',
name='t_history_created',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Дата создания записи истории. Автоматически устанавливается на текущее время при создании, но может быть отредактирована для загрузки исторических данных из Excel-файлов.', verbose_name='Дата создания'),
),
]
@@ -1,42 +0,0 @@
# Generated by Django 6.0.5 on 2026-06-13 18:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0003_remove_tbsource_l_source_currency_and_more'),
]
operations = [
migrations.RemoveField(
model_name='tbarticle',
name='k_article_to_styles',
),
migrations.RemoveField(
model_name='tbmusicstyle',
name='s_style_slug',
),
migrations.AddField(
model_name='tbitem',
name='k_item_to_style',
field=models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.', related_name='style_to_item', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили'),
),
migrations.AddField(
model_name='tbmusicstyle',
name='k_style_to_article',
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья о музыкальном стиле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_style', to='frontend.tbarticle', verbose_name='Связанная статья'),
),
migrations.AlterField(
model_name='tbarticle',
name='l_article_type',
field=models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи'),
),
migrations.AlterField(
model_name='tbsource',
name='l_source_type',
field=models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое (включая ручной ввод)')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника'),
),
]
@@ -0,0 +1,41 @@
# Generated by Django 6.0.5 on 2026-08-03 19:55
import django.db.models.deletion
import filer.fields.image
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0004_alter_tbarticle_k_article_to_image_and_more'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.RemoveField(
model_name='tboffer',
name='s_offer_skip32',
),
migrations.AddField(
model_name='tbarticle',
name='i_article_sort',
field=models.IntegerField(db_index=True, default=0, help_text='Сортировка/Приоритет для HUB. Статьи-хабы (и, возможно, обычные статьи) будут сортироваться в первую очередь по этому полю. Чем меньше число, тем выше приоритет. Используется для формирования меню, навигации, разделов и т.д.', verbose_name='Приоритет'),
),
migrations.AddField(
model_name='tbarticle',
name='j_article_metadata',
field=models.JSONField(blank=True, default=dict, help_text='JSON с дополнительными данными статьи. Может быть понадобится. Например, можно хранить информацию об источнике данных, авторе статьи, ссылках на внешние ресурсы, историю переименований slug и т.д.', null=True, verbose_name='Метаданные статьи'),
),
migrations.AlterField(
model_name='tbarticle',
name='k_article_to_image',
field=filer.fields.image.FilerImageField(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Для получения метаданных: TbImageMetadata.objects.filter(k_image_to_image=article.k_article_to_image).<br/><b>ВАЖНО</b>: Так как статья привязывается к исполнителям, лейблам, продавцам, музыкальным стилям, её изображение можно использовать как логотип, пиктограмму или баннер этих сущностей. Рекомендуется использовать прозрачный фон (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
),
migrations.AlterField(
model_name='tbarticle',
name='l_article_type',
field=models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('label', 'Label: Лейбл, издатель или компания'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('adv', 'Реклама или баннер'), ('HUB', 'СТАТЬЯ-ХАБ (использует DSL для отображения контента)'), ('txt', 'Текстовый контент (для условий конфиденциальности, правил, соглашений и т.д.)'), ('other', 'Другое')], db_index=True, default='other', max_length=7, verbose_name='Тип статьи'),
),
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+263 -1
View File
@@ -1,3 +1,265 @@
from django.shortcuts import render #
from dataclasses import dataclass
from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpRequest, HttpResponse, Http404
from frontend.models import TbArticle
from frontend.utils import get_hub_context, parse_article_metadata
from lpon_site.settings import *
import json
# Create your views here. # Create your views here.
@dataclass(frozen=True)
class BreadcrumbItem:
"""
Один пункт хлебных крошек (например, "Главная", "Каталог", "Винил").
ЧТО ТАКОЕ dataclass (если видите это в первый раз)
----------------------------------------------------
`@dataclass` это декоратор из стандартной библиотеки Python (модуль `dataclasses`).
Он берёт класс, в котором просто перечислены имена полей и их типы (без ручного
__init__), и САМ генерирует за вас служебные методы:
- __init__(self, title, url=None) конструктор класса. Благодаря нему можно
писать BreadcrumbItem(title="Каталог", url="/catalog") без единой строчки
кода конструктора;
- __repr__ красивое текстовое представление для print()/логов, например:
BreadcrumbItem(title='Каталог', url='/catalog');
- __eq__ сравнение двух объектов по значениям полей, то есть
BreadcrumbItem('Каталог', '/catalog') == BreadcrumbItem('Каталог', '/catalog')
вернёт True (без dataclass пришлось бы сравнивать id() объектов).
Без dataclass пришлось бы писать руками:
class BreadcrumbItem:
def __init__(self, title, url=None):
self.title = title
self.url = url
С dataclass просто пишем поля (см. ниже) и всё перечисленное выше Python
сгенерирует сам. Это НЕ меняет то, как объект используется просто экономит
код и снижает риск ошибок при ручном написании __init__/__repr__/__eq__.
Чем dataclass отличается от NamedTuple (typing.NamedTuple)?
---------------------------------------------------------------
Обе конструкции решают одну и ту же задачу описать "структуру из нескольких
полей" без лишнего кода, но по-разному устроены внутри:
- `NamedTuple` это, по сути, обычный tuple с именами полей. Он ВСЕГДА
неизменяем (immutable), поддерживает распаковку как обычный tuple
(title, url = item) и итерацию (for value in item);
- `dataclass` это обычный класс. По умолчанию он изменяем (можно
присвоить item.title = "..." в любой момент), но можно сделать
неизменяемым через параметр frozen=True (как сделано ниже). В отличие
от NamedTuple, dataclass НЕ поддерживает распаковку как tuple, зато его
проще расширять методами, наследованием и сложными полями по умолчанию
(списками, словарями и т.п.).
Для хлебных крошек подошёл бы любой из двух вариантов. Здесь выбран dataclass
с frozen=True то есть объект ведёт себя как неизменяемый: попытка сделать
`item.title = "другое значение"` после создания вызовет исключение. Это
логично: один и тот же пункт крошек не должен "мутировать" по ходу рендеринга
страницы.
КОНТРАКТ С ШАБЛОНОМ (важно!):
-------------------------------
Шаблон lpon_site/templates/block/breadcrumbs.html ожидает контекстную
переменную `breadcrumbs` список объектов именно с такими двумя полями:
Атрибуты:
title (str):
Видимый текст пункта, например "Каталог", "Винил".
url (str | None):
Ссылка на пункт. Если None (значение по умолчанию) пункт
считается ТЕКУЩЕЙ страницей: шаблон покажет его БЕЗ ссылки, с
атрибутом aria-current="page". Обычно url=None указывают только
у ПОСЛЕДНЕГО пункта в списке крошек.
ВАЖНО: пункт "Главная" в этот список включать НЕ нужно ссылку на
главную страницу (в виде иконки домика) шаблон breadcrumbs.html
добавляет сам, одинаково для всех страниц. Список крошек должен
начинаться сразу со следующего уровня (например, с "Каталог").
Пример использования см. функцию catalog() ниже.
"""
title: str
url: str | None = None
def index(request: HttpRequest | None) -> HttpResponse:
return render(request, 'index.html', {})
def catalog(request: HttpRequest | None) -> HttpResponse:
"""
Страница каталога (пока черновик вёрстки, см. lpon_site/templates/catalog.html).
ТЕСТОВЫЕ ДАННЫЕ ДЛЯ ХЛЕБНЫХ КРОШЕК:
В реальной вьюхе список крошек будет собираться динамически (в зависимости
от применённых фильтров, выбранной категории и т.п.). Пока каталог черновик,
здесь захардкожен простой пример из двух пунктов, чтобы продемонстрировать
работу block/breadcrumbs.html: "Каталог" обычный пункт со ссылкой, а
"Компакт-кассеты" текущая страница (url не передан, поэтому он покажется без
ссылки). Пункт "Главная" передавать не нужно его в виде иконки домика
сам добавляет шаблон breadcrumbs.html.
"""
breadcrumbs = [
BreadcrumbItem(title="Каталог", url="/catalog"),
BreadcrumbItem(title="Компакт-кассеты", url="/catalog/compact-cassettes"), # url передан -> обычный пункт со ссылкой
BreadcrumbItem(title="Для перезаписи" ), # url передан -> обычный пункт со ссылкой
]
return render(request, 'catalog.html', {"breadcrumbs": breadcrumbs})
def info_articles_list(request: HttpRequest | None) -> HttpResponse:
"""
Представление (View) для отображения списка текстовых статей (тип ArticleType.TXT).
Функциональность:
-----------------
1. Выбирает из БД все опубликованные статьи (b_article_published=True) с типом 'txt'.
2. Сортирует их по полю приоритета i_article_sort (по возрастанию, чем меньше число
тем выше в списке), затем по дате создания (-t_article_created) и названию.
3. Оптимизирует выборку связанного файла обложки (k_article_to_image) через select_related,
чтобы избежать N+1 запросов при рендеринге изображений в списке.
4. Формирует хлебные крошки (BreadcrumbItem) с элементом "Инструкции".
5. Рендерит шаблон `info_list.html`, передавая в контекст список статей и хлебные крошки.
Аргументы:
request (HttpRequest | None): Объект HTTP-запроса Django.
Возвращает:
HttpResponse: Сформированная HTML-страница со списком текстовых статей.
"""
# Выбираем опубликованные текстовые статьи из базы данных с правильной сортировкой
articles = (
TbArticle.objects.filter(
l_article_type=TbArticle.ArticleType.INFO,
b_article_published=True,
)
.select_related('k_article_to_image')
.order_by('i_article_sort', '-t_article_created', 's_article_title')
)
# Формируем цепочку хлебных крошек (пункт "Главная" добавляется автоматически в шаблоне)
breadcrumbs = [
BreadcrumbItem(title="Инструкции"),
]
# Передаём контекст в шаблон списка текстовых статей
context = {
"articles": articles,
"breadcrumbs": breadcrumbs,
}
return render(request, "roll/info_list.html", context)
def info_article_detail(request: HttpRequest | None, slug: str) -> HttpResponse:
"""
Представление (View) для детального отображения отдельной текстовой статьи (тип ArticleType.INFO).
Функциональность:
-----------------
1. Ищет опубликованную статью (b_article_published=True) по её слагу (slug).
Если статья не найдена или не опубликована возвращает HTTP 404 (Page Not Found).
2. Вызывает метод `increment_views()` у объекта статьи для атомарного увеличения
счётчика просмотров `i_article_views`.
3. Формирует цепочку хлебных крошек (BreadcrumbItem):
- "Инструкции" (со ссылкой на /info/);
- Заголовок текущей статьи (без ссылки, текущий пункт).
4. Рендерит шаблон `content/article_detail.html`, передавая объект статьи и крошки.
Аргументы:
request (HttpRequest | None): Объект HTTP-запроса Django.
slug (str): Уникальный URL-слаг статьи.
Возвращает:
HttpResponse: Сформированная HTML-страница детального просмотра статьи.
"""
# Получаем опубликованную статью по слагу или отдаем 404 Not Found
article = get_object_or_404(
TbArticle,
slug=slug,
b_article_published=True,
)
# Безопасно инкрементируем счетчик просмотров статьи
article.increment_views()
# Формируем цепочку хлебных крошек (пункт "Главная" добавляется автоматически в шаблоне)
article_title = article.s_article_title_html or article.s_article_title
breadcrumbs = [
BreadcrumbItem(title="Инструкции", url="/info/"),
BreadcrumbItem(title=article_title),
]
# Получаем контекст с учетом возможных блоков HUB DSL
context = get_hub_context(article)
context["breadcrumbs"] = breadcrumbs
return render(request, "content/article_detail.html", context)
def hub_detail(request: HttpRequest, slug: str) -> HttpResponse:
"""
Универсальное представление (View) для хабов (включая страницы, запрашиваемые из корня сайта /<slug>).
Функциональность:
-----------------
1. Ищет опубликованную статью (b_article_published=True) по её слагу (slug).
2. Если статья принадлежит к каноническому типу с отдельным роутом (не HUB и не OTHER),
выполняет HTTP 301 Permanent Redirect на её канонический адрес (article.get_absolute_url()).
3. Валидирует j_article_metadata и наличие ключа HUB. Если статья хаб, но метаданные
невалидны или ключ HUB отсутствует отдаёт HTTP 404 status с подсказкой для администратора.
4. Увеличивает счётчик просмотров статьи, формирует хлебные крошки и рендерит шаблон хаба.
Аргументы:
request (HttpRequest): Объект HTTP-запроса Django.
slug (str): Уникальный URL-слаг хаба или страницы.
Возвращает:
HttpResponse: Сформированная HTML-страница хаба либо страница ошибки 404.
"""
# Ищем опубликованную статью по запрошенному слагу
article = TbArticle.objects.filter(
slug=slug,
b_article_published=True,
).first()
# Если статья не найдена — отдаём честный статус 404
if article is None:
admin_hint = (f"Статья или хаб со&nbsp;слагом «<strong>{ slug }</strong>» отсутствует в&nbsp;базе данных."
f" Создайте статью со&nbsp;слагом «<strong>{ slug }</strong>» (тип&nbsp;<code>HUB</code>)"
" в&nbsp;админис&shy;тративной панели.")
return render(request, "404.html", {"admin_hint": admin_hint}, status=404)
# Если нашлась статья с таким слагом и у неё каноническая ветка роутинга — 301-редирект
if article.l_article_type not in (TbArticle.ArticleType.HUB, TbArticle.ArticleType.OTHER):
return redirect(article.get_absolute_url(), permanent=True)
# Проверяем и валидируем метаданные j_article_metadata
metadata, error_msg = parse_article_metadata(article)
if error_msg or not metadata:
admin_hint = (f"Статья со&nbsp;слагом «<strong>{ slug }</strong>» (<code>id={ article.id }</code>) объявлена"
f" как&nbsp;хаб, но&nbsp;у&nbsp;неё <u>{ error_msg }</u>. Добавьте описание хаба (ключ"
f" <code>{ KEY_ARTICLE_HUB }</code>) в&nbsp;метаданные через&nbsp;<a target='_blank'"
f" href='/{ADMIN_URL}frontend/tbarticle/{ article.id }/change/'>адми&shy;нистра&shy;тивную панель</a>.")
return render(request, "404.html", {"admin_hint": admin_hint}, status=404)
# Проверяем, что в метаданных есть ключ HUB
if KEY_ARTICLE_HUB not in metadata:
admin_hint = (f"Статья со&nbsp;слагом «<strong>{ slug }</strong>» (<code>id={ article.id }</code>) объявлена"
f" как&nbsp;хаб, но&nbsp;в&nbsp;её&nbsp;мета-данных нет ключа <code>{ KEY_ARTICLE_HUB }</code>"
" с&nbsp;описанием хаба. Внесите изменения в&nbsp;<a target='_blank'"
f" href='/{ADMIN_URL}frontend/tbarticle/{ article.id }/change/'>адми&shy;нистра&shy;тивную панели</a>.")
return render(request, "404.html", {"admin_hint": admin_hint}, status=404)
# Формируем контекст с данными хаба и хлебными крошками
context = get_hub_context(article, metadata)
article_title = article.s_article_title_html or article.s_article_title
context["breadcrumbs"] = [
BreadcrumbItem(title=article_title),
]
# Безопасно увеличиваем счетчик просмотров
article.increment_views()
return render(request, "content/hub.html", context)
+29 -2
View File
@@ -48,6 +48,13 @@ ALLOWED_HOSTS = env.list(
CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver']) CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver'])
# ====== HASHIDS настройка для s_offer_code ======
# Криптографическое кодирование ID оферов в компактные, необратимые коды.
# Благодаря hashids невозможно восстановить исходный ID из кода,
# даже зная алгоритм (в отличие от простого XOR).
OFFER_HASHIDS_SALT = env('OFFER_HASHIDS_SALT', default='your-secret-salt-change-me')
OFFER_HASHIDS_MIN_LENGTH = env.int('OFFER_HASHIDS_MIN_LENGTH', default=6)
######################################### #########################################
# Настройки сообщений об ошибках когда все упало и т.п. # Настройки сообщений об ошибках когда все упало и т.п.
ADMINS = tuple( ADMINS = tuple(
@@ -91,13 +98,14 @@ ROOT_URLCONF = 'lpon_site.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'frontend.context_processors.site_context',
], ],
}, },
}, },
@@ -317,7 +325,26 @@ LOGGING = {
SLUG_MAX_LENGTH = 60 SLUG_MAX_LENGTH = 60
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.) # Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
KEY_SYNONYM = 'SYNONYM' KEY_SYNONYM_EN = 'SYN_EN' # Ключ для хранения синонимов на английском языке (для поиска по синонимам) и ДЛЯ ПАРСЕРОВ
KEY_SYNONYM_RU = 'SYN_RU' # Ключ для хранения синонимов на русском языке (для представления на фронтенд)
# Ключи и значения для метаданных картинок
KEY_IMAGE_TYPE = 'IMG_IS' # Ключ для хранения типа картинки
VALUE_IMAGE_ABSTRACT = 'abstract' # Абстрактная картинка (лого лейбла, фото исполнителя, типовая обложка альбома...)
VALUE_IMAGE_REAL = 'real' # Реальная картинка (актуально, для фото товаров в TbOffer)
KEY_IMAGE_FROM = 'IMG_FROM' # Ключ, о том, как получено изображение
VALUE_IMAGE_FROM_USER = '_adm' # Картинка загружена пользователем через админку
VALUE_IMAGE_FROM_PARSE = 'parse' # Картинка получена через парсер (например, с сайта Discogs, Bandcamp и т.д.)
KEY_IMAGE_URL = 'IMG_URL' # Ключ для хранения URL оригинальной картинки (обычно это должен прописать парсер)
KEY_IMAGE_NOTE = 'IMG_NOTE' # Ключ для хранения заметки о картинке
# Ключи и значения для коммерческих предложений (офферов, см. TbOffer и OfferAdmin)
KEY_OFFER_NOTE = 'OFR_NT' # Ключ для заметок по офферу (например, "замят угол конверта" и т.д.)
KEY_OFFER_ALL_MEDIA = 'OFR_MF' # Ключ для хранения информации о том, какие носители входят в оффер (например, CD+2LP)
# Ключи метанных статей (TbArticle).
KEY_ARTICLE_HUB = 'HUB' # Ключ для хранения информации о том, что статья является хабом (содержит ссылки на другие статьи)
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.) # ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE' VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'
+11 -1
View File
@@ -15,13 +15,23 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path, include, re_path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from frontend import views
urlpatterns = [ urlpatterns = [
# Админ-сайт с переименованными приложениями (переопределен в frontend/apps.py) # Админ-сайт с переименованными приложениями (переопределен в frontend/apps.py)
path(settings.ADMIN_URL, admin.site.urls), path(settings.ADMIN_URL, admin.site.urls),
re_path(r'^$', views.index),
re_path(r'^catalog/$', views.catalog),
re_path(r'^info/?$', views.info_articles_list, name='info_articles_list'),
re_path(r'^info/(?P<slug>[-a-zA-Z0-9_]+)/?$', views.info_article_detail, name='info_article_detail'),
# Универсальный роут для хабов и страниц из корня (должен идти ПОСЛЕДНИМ!)
re_path(r'^(?P<slug>[-a-zA-Z0-9_]+)/?$', views.hub_detail, name='hub_detail'),
] ]
# Сервировать медиа-файлы в разработке (в production использовать nginx/Apache) # Сервировать медиа-файлы в разработке (в production использовать nginx/Apache)
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>{% load static %}
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Ошибка 404 — LPON — Магазин виниловых пластинок и аудиокассет" />
<meta name="keywords" content="vinyl, audio, music, LPON" />
<meta name="copyright" lang="ru" content="Sergei Erjemin" />
<meta name="robots" content="noindex, nofollow" />
<title>ОШИБКА 404 — LPON.RU</title>
<meta name="meta-title" content="ОШИБКА 404 — LPON.RU" />
<link rel="canonical" href="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}" />
{# Шутка #}<meta name="generator" content="Microsoft FrontPage 1.0" />
<link rel="icon" type="image/svg+xml" href="{% static 'svgs/favicon.svg' %}" />
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}" />{% if IS_DEBUG %}{% comment %}
Переключение dev/prod стилей Tailwind CSS происходит
АВТОМАТИЧЕСКИ через переменную IS_DEBUG (см. контексный-процессор
frontend/context_processors.py), DEV: tailwindcss из CDN, компилируется прямо в браузере. Удобно
для разработки (не нужна сборка), но грузит браузер и prod не используется. {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>{% comment %}
Кастомные @theme/@layer директивы проекта (токены цвета, типовой стиль ссылок)
лежат в css/tailwind-custom.css. @tailwindcss/browser сканирует и компилирует ТОЛЬКО то, что
видит в финальном DOM — то есть только содержимое тегов style с type text/tailwindcss, а не
внешние .css, подключённые через link. Поэтому файл вставляется сюда через include, а не
подключается отдельным link.{% endcomment %}
<style type="text/tailwindcss">
{% include "css/tailwind-custom.css" %}
</style>{% else %}{% comment %} PROD: заранее собранный Tailwind CLI файл (см.
frontend-assembly/tailwind/input.css и package.json). CLI сканирует шаблоны проекта и генерирует
только реально используемые классы, включая кастомные @theme/@layer директивы из того же самого
файла css/tailwind-custom.css (подключён в input.css через @import).{% endcomment %}
<link rel="stylesheet" href="{% static 'css/tailwind.min.css' %}" />{% endif %}
<link rel="stylesheet" href="{% static 'css/lpon.css' %}" />
</head>
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<!-- HERO -->
<section class="relative w-full h-screen flex items-center justify-center overflow-hidden">
<!-- Фон из двух картинок -->
<div class="absolute inset-0 blur-[1px]">
<div class="absolute top-0 left-0 w-full h-1/2 bg-[url({% static 'svgs/lp.svg' %})] bg-bottom-right bg-no-repeat border-purple-950 border-b-1 border-dotted"></div>
<div class="absolute bottom-0 right-0 w-full h-1/2 bg-[url({% static 'svgs/cc.svg' %})] bg-bottom-right bg-no-repeat border-orange-950 border-b-1 border-dotted rotate-180"></div>
</div>
<!-- Содержимое поверх фона -->
<div class="relative z-10 text-center max-w-2xl mx-auto px-6 bg-[#c0c0c0e0] dark:bg-[#303030e0] shadow-lg shadow-gray-300 dark:shadow-gray-900 px-40 py-20 rounded-4xl border-gray-300 dark:border-gray-700 border-2">
<picture>
<source srcset="{% static 'svgs/logo-lpon-d.svg' %}" media="(prefers-color-scheme: dark)" class="h-32 mx-auto mb-8">
<img src="{% static 'svgs/logo-lpon-l.svg' %}" alt="Логотип lpon.ru" class="h-32 mx-auto mb-8">
</picture>
<h1 class="text-5xl font-bold mb-4 drop-shadow-lg font-serif">Ошибка&nbsp;404</h1>
<p class="text-2xl drop-shadow font-serif">Страница не&nbsp;найдена…</p>
<p class="my-10 text-slate-500 dark:text-slate-500">Запрошенная страница не&nbsp;существует или&nbsp;была перемещена.</p>
{% if user.is_staff and admin_hint %}
<div class="mb-18 rounded-xl border border-amber-300 bg-amber-50 p-6 text-left text-amber-900 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-200">
<div class="font-bold text-amber-800 dark:text-amber-300">Администратор!</div>
<p class="mt-2 leading-relaxed">{{ admin_hint|safe }}</p>
</div>{% endif %}
<a href="/" class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold drop-shadow-lg">
&larr; На главную страницу
</a>
</div>
<div class="absolute bottom-4 right-4 text-s text-gray-600 dark:text-gray-400 font-serif">
&copy; 2026, Sergei Erjemin
</div>
</section>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>{% load static %}
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="{% block DESCRIPTION %}LPON — Магазин виниловых пластинок и аудиокассет{% endblock %}" />
<meta name="keywords" content="{% block KEYWORDS %}vinyl, audio, music, LPON{% endblock %}" />
<meta name="copyright" lang="ru" content="Sergei Erjemin{% block META_COPYRIGHT_AUTHOR %}{% endblock %}." />
<meta name="robots" content="{% block ROBOTS %}index,follow{% endblock %}" />
<meta name="format-detection" content="telephone=no" />
<title>{% block PAGE_TITLE %}LPON.RU{% endblock %}</title>
<meta name="meta-title" content="{% block META_TITLE %}LPON.RU{% endblock %}" />
<meta name="meta-description" content="{% block META_DESCRIPTION %}LPON — Магазин виниловых пластинок и аудиокассет{% endblock %}" />
<link rel="canonical" href="{% block CANONICAL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
<meta property="og:site_name" content="LPON.RU" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:title" content="{% block OG_TITLE %}LPON.RU{% endblock %}" />
<meta property="og:description" content="{% block OG_DESCRIPTION %}LPON — Магазин виниловых пластинок и аудиокассет{% endblock %}" />
<meta property="og:url" content="{% block OG_URL %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
<meta property="og:type" content="{% block OG_TYPE %}website{% endblock %}" />
<meta property="og:image" content="{% block OG_IMAGE %}{% static 'img/og-lpon-default.png' %}{% endblock %}" />
<meta property="og:image:alt" content="{% block OG_IMAGE_ALT %}LPON.RU{% endblock %}" />
<meta name="twitter:card" content="{% block TWITTER_CARD %}summary_large_image{% endblock %}" />
<meta name="twitter:title" content="{% block TWITTER_TITLE %}LPON.RU{% endblock %}" />
<meta name="twitter:description" content="{% block TWITTER_DESCRIPTION %}LPON — Магазин виниловых пластинок и аудиокассет{% endblock %}" />
<meta name="twitter:image" content="{% block TWITTER_IMAGE %}{% static 'img/og-lpon-default.png' %}{% endblock %}" />
<meta name="theme-color" content="#F5F5F5" />
{# Шутка #}<meta name="generator" content="Microsoft FrontPage 1.0" />
<link rel="icon" type="image/svg+xml" href="{% static 'svgs/favicon.svg' %}" />
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}" />{% if IS_DEBUG %}{% comment %}
Переключение dev/prod стилей Tailwind CSS происходит
АВТОМАТИЧЕСКИ через переменную IS_DEBUG (см. контексный-процессор
frontend/context_processors.py), DEV: tailwindcss из CDN, компилируется прямо в браузере. Удобно
для разработки (не нужна сборка), но грузит браузер и prod не используется. {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>{% comment %}
Кастомные @theme/@layer директивы проекта (токены цвета, типовой стиль ссылок)
лежат в css/tailwind-custom.css. @tailwindcss/browser сканирует и компилирует ТОЛЬКО то, что
видит в финальном DOM — то есть только содержимое тегов style с type text/tailwindcss, а не
внешние .css, подключённые через link. Поэтому файл вставляется сюда через include, а не
подключается отдельным link.{% endcomment %}
<style type="text/tailwindcss">
{% include "css/tailwind-custom.css" %}
</style>{% else %}{% comment %} PROD: заранее собранный Tailwind CLI файл (см.
frontend-assembly/tailwind/input.css и package.json). CLI сканирует шаблоны проекта и генерирует
только реально используемые классы, включая кастомные @theme/@layer директивы из того же самого
файла css/tailwind-custom.css (подключён в input.css через @import).{% endcomment %}
<link rel="stylesheet" href="{% static 'css/tailwind.min.css' %}" />{% endif %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "LPON.RU",
"url": "{{ request.scheme }}://{{ request.get_host }}/"
}
</script>{% block EXTRAHEAD %}{% endblock EXTRAHEAD %}
<link rel="stylesheet" href="{% static 'css/lpon.css' %}" />
</head>
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}{% include "block/header.html" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8 font-serif">
{% block CONTENT %}{% endblock CONTENT %}
</main>
{# {% include "blocks/footer.jinja2" %} #}{% endblock BODY %}{% if not ALLOW_TRACKING %}{# Если клиент еще не согласился на отслеживание покажем ему уведомление #}{% include "block/allow-tracking.html" %}{% else %}{# Если согласился -- отслеживаем#}
<script src="{% static 'js/analytics.js' %}"></script>{% endif %}
<script src="{% static 'js/alpine.min.js' %}"></script>
<script src="{% static 'js/htmx.min.js' %}"></script>{% block ADD_JS1 %}{% endblock ADD_JS1 %}{% block ADD_JS2 %}{% endblock ADD_JS2 %}{% block ADD_JS3 %}{% endblock ADD_JS3 %}
</body>
</html>
@@ -0,0 +1,13 @@
{# НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}<div id="allow-tracking-popup" class="sticky bottom-0 z-50 bg-gray-100/80 dark:bg-slate-800/70 backdrop-blur ">
<div class="max-w-7xl mx-auto px-4 py-8 border-b border-gray-200 dark:border-gray-800 grid grid-cols-[auto_1fr] gap-x-4
px-4 py-0">
<p class="flex-col px-6 py-6">
В&nbsp;соответствии с&nbsp;<abbr title="General Data Protection Regulation">GDPR</abbr>
и&nbsp;152-<abbr title="Федеральный закон">ФЗ</abbr>, уведомляем вас, что сайт lpon.ru с&nbsp;целью аналитики
и&nbsp;улучшения своей работы использует файлы cookie и&nbsp;инструменты сбора обезли&shy;ченных данных
о&nbsp;поведении пользо&shy;вателей. Оставаясь на&nbsp;сайте, вы&nbsp;соглашаетесь с&nbsp;нашей
<a href="/info/privacy-policy">политикой конфи&shy;денциаль&shy;ности</a>.
</p>
<button id="cookie-accept" class="text-lg px-6 py-2 my-6 border-2 rounded-md">Принять</button>
</div>
</div>{# / НИЖНИЙ POPUP О КУКУХ И ОТСЛЕЖИВАНИИ #}
@@ -0,0 +1,73 @@
{% load static %}{% comment %}
Шаблон "Хлебные крошки" (breadcrumbs).
ЗАЧЕМ ЭТОТ БЛОК:
Показывает пользователю путь от главной страницы до текущей
("Главная > Каталог > Винил > The Beatles > Abbey Road"),
помогая ориентироваться в структуре сайта + SEO
Первый пункт (ссылка "Главная") — вставляется САМИМ этим шаблоном,
без участия контекста. Она одинакова абсолютно на всех страницах
сайта, поэтому передавать её через каждую вьюху не нужно: переменная
`breadcrumbs`, которую передаёт вызывающая вьюха, НЕ должна содержать
пункт "Главная" — начинайте сразу со следующего уровня (например,
"Каталог"), см. КОНТРАКТ ниже.
Вместо слова "Главная" используется готовая иконка домика из статики
проекта — public/static/svgs/ico-home.svg, подключаемая через <img />,
БЕЗ подключения иконочного шрифта (Font Awesome, IcoMoon и т.п.).
Пока иконок в проекте немного, тянуть ради них целый шрифт (лишние
килограммы + лишний HTTP-запрос + зависимость от CDN) — невыгодно;
один SVG-файл дешевле и не требует внешних подключений.
ВАЖНО про цвет иконки: иконка из img-тега НЕ наследует
цвет текста и не меняется на hover — она остаётся такой, какая
нарисована в самом файле (чёрная заливка). Чтобы иконка оставалась
видимой на тёмном фоне в тёмной теме, применён CSS-фильтр dark:invert
(инвертирует чёрный в белый только в тёмной теме, в светлой теме
фильтр не применяется).
КОНТРАКТ (что именно должна передать вызывающая вьюха/шаблон):
Контекстная переменная `breadcrumbs` — список (или любой другой iterable)
объектов с двумя атрибутами (БЕЗ пункта "Главная" — его добавляет сам
шаблон, см. выше):
- title: str — видимый текст пункта ("Каталог", "Винил"...);
- url: str | None — ссылка на пункт.
Если url = None (или отсутствует/пуст) — пункт
считается ТЕКУЩЕЙ страницей и рендерится БЕЗ ссылки,
с атрибутом aria-current="page".
Обычно url=None указывают только у ПОСЛЕДНЕГО пункта.
В проекте для этого используется dataclass `BreadcrumbItem` — см. подробное
объяснение (с комментариями "для новичков") прямо в docstring класса в файле
lpon_site/frontend/views.py. Шаблону не важно, dataclass это, NamedTuple или
просто объект с нужными атрибутами — главное, чтобы у каждого элемента были
поля `title` и `url`.
{% endcomment %}{% if breadcrumbs %}<nav aria-label="Хлебные крошки" class="px-4 py-2 text-sm text-slate-500 dark:text-slate-100 hidden md:block bg-slate-50 dark:bg-slate-900 rounded-xl">{% comment %}
{# aria-label="Хлебные крошки" — для доступности, чтобы скринридеры озвучивали #}
{# пользователю, что это именно хлебные крошки, а не просто список ссылок. #}
{# hidden md:block — скрываем на маленьких экранах (мобильных) #}{% endcomment %}
<ol class="flex flex-wrap items-center gap-x-2">{% comment %}
{# ol вместо ul — потому что это упорядоченный список, а не просто #}
{# набор ссылок. Важный момент для доступности и SEO. #}{% endcomment %}
<li class="flex items-center gap-x-2">{% comment %}{# Ссылка на главную (домик) #}
{# пунктирное подчёркивание и цвет теперь задаются типовым стилем #}
{# ссылок (см. css/tailwind-custom.css, @layer base { a {...} }); #}
{# opacity-70/hover/transition — собственный эффект именно для иконки-домика. #}{% endcomment %}
<a href="/" aria-label="Главная" class="opacity-70 hover:opacity-100 transition-opacity py-0.5">
<img src="{% static 'svgs/ico-home.svg' %}" alt="" aria-hidden="true" class="w-5 h-4 dark:invert">
</a>
</li>{% for crumb in breadcrumbs %}
<li class="flex items-center gap-x-2">
{# Разделитель перед пунктом #}<span aria-hidden="true" class="text-slate-400 dark:text-slate-600">/</span>
{% if crumb.url and not forloop.last %}{% comment %}
{# Обычный пункт со ссылкой (не последний и с заданным url). #}
{# Класс не нужен — типовое оформление ссылки (пунктир, цвет, ховер) #}
{# задаётся автоматически через css/tailwind-custom.css, @layer base { a {...} }. #}
{% endcomment %}<a href="{{ crumb.url }}">{{ crumb.title }}</a>{% else %}{% comment %}
{# Текущая страница: без ссылки, с aria-current="page" для доступности и SEO #}
{% endcomment %}<span aria-current="page" class="font-medium">{{ crumb.title|safe }}</span>{% endif %}
</li>{% endfor %}
</ol>
</nav>{% endif %}
+29
View File
@@ -0,0 +1,29 @@
{% load static %}<header x-data="{scrolled:false}" @scroll.window="scrolled=(window.scrollY>50)" class="sticky top-0
z-50 bg-gray-100/90 dark:bg-slate-800/70 backdrop-blur border-b border-gray-200 dark:border-gray-800
bg-[url({% static 'svgs/cc.svg' %})] bg-bottom-right bg-no-repeat grid grid-cols-[1fr_auto_1fr] items-center
content-center gap-x-4 px-4 py-0">{% comment %}
Пункты меню — ссылки-кнопки, а не типовой текст, поэтому у каждой <a> явно
заданы border-0 (отключает пунктирное подчёркивание из общего стиля ссылок) и свой
цвет text-slate-900/dark:text-slate-100 (см. пояснение в css/tailwind-custom.css).{% endcomment %}
{# ЛЕВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-end text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">ВИНИЛ</a>
<span class="px-4 py-2 bg-gray-300 dark:bg-gray-600 rounded-lg hidden md:block">КАССЕТЫ</span>
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block border-0 text-slate-900 dark:text-slate-100">MD</a>
</menu>{# /ЛЕВОЕ МЕНЮ #}
{# ЛОГОТИП В ЦЕНТРЕ #}<div class="flex flex-col items-center justify-self-center px-6">{% comment %}
border-0 — логотип оборачивается в ссылку без текста, отключаем типовое
пунктирное подчёркивание ссылки, которое иначе появится под картинкой.{% endcomment %}
<a href="/" class="block leading-none border-0">
<picture id="logo" class="block my-2 leading-none" :class="scrolled?'px-0':'px-6'">
<source srcset="{% static 'svgs/logo-lpon-d.svg' %}" media="(prefers-color-scheme: dark)" class="mx-auto transition-all duration-300" :class="scrolled?'h-12':'h-18'">
<img src="{% static 'svgs/logo-lpon-l.svg' %}" alt="Логотип lpon.ru" class="mx-auto block transition-all duration-300" :class="scrolled?'h-12':'h-18'">
</picture>
</a>
</div>{# /ЛОГОТИП В ЦЕНТРЕ #}
{# ПРАВОЕ МЕНЮ #}<menu class="flex items-center gap-x-4 justify-self-start text-xl font-semibold text-slate-900 dark:text-slate-100">
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg border-0 text-slate-900 dark:text-slate-100">CD</a>
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden md:block hyphens-none border-0 text-slate-900 dark:text-slate-100">BLU-RAY</a>
<a href="#" class="px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg hidden lg:block border-0 text-slate-900 dark:text-slate-100">АКСЕССУАРЫ</a>
</menu>{# /ПРАВОЕ МЕНЮ #}
{# СЛОГАН #}<p id="slogan" x-show="!scrolled" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="col-span-3 text-xs tracking-widest text-gray-300 bg-black/80 dark:text-gray-300 dark:bg-gray-400/80 px-6 pt-1 pb-1 mb-4 leading-none rounded-lg justify-self-center">Живой звук C-46, C-60, C-90 и&nbsp;далее…</p>{# /СЛОГАН #}
</header>
+16
View File
@@ -0,0 +1,16 @@
{% extends "_base.html" %}{% load static %}
{% block DESCRIPTION %}LPON — Магазин виниловых пластинок и аудиокассет{% endblock %}
{% block CONTENT %}
{# Хлебные крошки. Переменная `breadcrumbs` передаётся из вьюхи catalog() #}
{# (см. lpon_site/frontend/views.py, класс BreadcrumbItem и docstring вьюхи) #}
{% include "block/breadcrumbs.html" %}
<div class="grid grid-cols-6 gap-4 h-lvh">
<div class="col-span-4 col-start-2 bg-blue-500 ...">01 Каталог</div>
<div class="col-start-1 col-end-3 bg-blue-600 ...">02 Блок</div>
<div class="col-span-2 col-end-7 bg-blue-500 ...">03 Блок</div>
<div class="col-start-1 col-end-7 bg-blue-200 ...">04 Блок</div>
</div>
{% endblock CONTENT %}
@@ -0,0 +1,49 @@
{% extends "_base.html" %} {% load static %}
{# SEO Мета-данные статьи #}
{% block PAGE_TITLE %}{{ article.seo_title|default:article.s_article_title_html|striptags|default:article.s_article_title }} — LPON.RU{% endblock %}
{% block DESCRIPTION %}{{ article.seo_description|default:article.s_article_teaser_html|striptags|default:"" }}{% endblock %}
{% block CONTENT %}{# Подключение блока хлебных крошек #}{% include "block/breadcrumbs.html" %}{% comment %}
КОНТЕНТ НАЧАЛО
{% endcomment %} <article class="mx-auto grid grid-cols-3 gap-8 py-6">
{# ПРАВАЯ КОЛОНКА / Обложка / Иллюстрация статьи, если есть #}<div class="mb-8 max-h-[480px] overflow-hidden">
{% if article.k_article_to_image %}<img src="{{ article.k_article_to_image.url }}" alt="{{ article.k_article_to_image.default_alt_text|default:article.s_article_title }}" class="h-auto w-full rounded-xl border border-slate-200 object-cover dark:border-slate-800" />{% else%}<picture id="logo" class="block my-2 leading-none" :class="scrolled?'px-0':'px-6'">
<source srcset="{% static 'svgs/logo-lpon-d.svg' %}" media="(prefers-color-scheme: dark)" class="mx-auto transition-all duration-300" :class="scrolled?'h-12':'h-18'">
<img src="{% static 'svgs/logo-lpon-l.svg' %}" alt="Логотип lpon.ru" class="mx-auto block transition-all duration-300" :class="scrolled?'h-12':'h-18'">
</picture>{% endif %}
</div>
{# ЛЕВАЯ КОЛОНКА / Основной текст статьи #}<div class="col-span-2">
<!-- Заголовл -->
<h1 class="mb-4 text-4xl leading-tight font-light text-slate-900 md:text-5xl dark:text-slate-100">{% if article.s_article_title_html %}{{ article.s_article_title_html|safe }}{% else %}{{ article.s_article_title }}{% endif %}</h1>
<!-- /Заголовок -->
<!-- Мета-данные -->
<div class="mb-6 items-center gap-4 border-b border-slate-200 pb-4 text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
<time datetime="{{ article.t_article_created|date:'Y-m-d' }}">{{ article.t_article_created|date:"d.m.Y" }}</time>
</div>
<!-- /Мета-данные -->
<!-- Тизер -->{% if article.s_article_teaser_html %}
<div class="mb-6 rounded-r-lg border-l-4 border-slate-400 bg-slate-50 p-4 text-lg leading-relaxed text-slate-700 italic md:text-xl dark:border-slate-600 dark:bg-slate-900/60 dark:text-slate-300">
{{ article.s_article_teaser_html|safe }}
</div>{% endif %}
<!-- /Тизер -->
<!-- Основное содержимое -->{% if article.s_article_content_html %}
<div class="article-content max-w-none">
{{ article.s_article_content_html|safe }}
</div>
<!-- /Основное содержимое -->{% endif %}
{# Блоки HUB DSL, если статья содержит расширенную структуру #}
{% if hub_data %}
<div class="my-8 rounded-xl border border-dashed border-slate-300 p-6 dark:border-slate-700">
<h2 class="mb-2 text-lg font-semibold text-slate-800 dark:text-slate-200">Дополнительные блоки (HUB DSL):</h2>
<pre class="overflow-x-auto text-xs text-slate-600 dark:text-slate-400">{{ hub_data }}</pre>
</div>
{% endif %}
<!-- Кнопка возврата к списку -->
<div class="mt-10 border-t border-slate-200 pt-6 dark:border-slate-800">
<a href="{% url 'info_articles_list' %}" class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100">&larr; Назад к списку материалов</a>
</div>
<!-- /Кнопка возврата к списку -->
</div>
</article>{% endblock CONTENT %}
+150
View File
@@ -0,0 +1,150 @@
/*
tailwind-custom.css кастомные Tailwind-директивы проекта lpon.ru
(c) 2026, Sergei Erjemin, https://lpon.ru
{% comment %}
ВНИМАНИЕ: этот файл не просто CSS! Это Django-шаблон, который подключается через
include-тег в инлайновый блок <style type="text/tailwindcss"> шаблона _base.html.
ВАЖНО: этот файл содержит ТОЛЬКО директивы @theme/@layer, которые обязаны
пройти через компилятор Tailwind, поэтому подключать его напрямую через
<link rel="stylesheet"> НЕЛЬЗЯ обычный браузерный CSS-парсер не понимает
@theme/@apply и просто отбросит эти правила (проверено на практике: именно
так "молчал" прежний lpon.css).
ОЧЕНЬ ВАЖНО: Так как этот CSS-файл сам является Django-шаблоном (подключается
инлайн через include-тег движка шаблонов), то внутри него НЕЛЬЗЯ писать буквенные
примеры Django-тегов вида "открыть-процент include ... процент-закрыть"
движок шаблонов исполнит их по-настоящему, даже внутри CSS-комментария.
Это может привести к бесконечным рекурсиям (файл случайно подключал
сам себя) и другим непредсказуемым последствиям.
Если использовать комментарии для шаблонов Django, как здесь, то это безопасно.
Файл используется в ДВУХ местах одновременно:
1. В dev подключается инклюд-тегом движка шаблонов Django ВНУТРИ
инлайнового блока <style type="text/tailwindcss"> в _base.html.
Именно этот блок сканирует и компилирует CDN-скрипт
@tailwindcss/browser прямо в браузере (он видит только финальный DOM,
а не внешние .css-файлы, подключённые через <link>).
2. В prod подключается через нативный CSS @import из
frontend-assembly/tailwind/input.css в сборщике scripts/build-tailwind.sh.
Результат Tailwind CLI собирает в public/static/css/tailwind.min.css
(см. frontend-assembly/tailwind/package.json).
Поэтому здесь НЕТ строки `@import "tailwindcss";` её добавляет каждый
"потребитель" данного файла САМ, непосредственно перед подключением этого
файла: инлайновый блок в _base.html (в dev) и input.css (в prod). Без
этого импорта директива @apply внутри @layer base ниже не сможет найти
используемые в ней стандартные классы (border-slate-500 и т.п.) и упадёт
с ошибкой "Cannot apply unknown utility class" при этом в dev это ломает
ВООБЩЕ ВСЮ генерацию Tailwind CSS на странице (проверено на практике).
{% endcomment %} */
@theme { /* -- Кастомные переменные цветов -- {% comment %}
@theme задаёт кастомные токены цвета, которые можно использовать
в Tailwind-классах через синтаксис, например, text-(--color-link)
Это позволяет менять стили глобально, не бегая по шаблонам и не меняя конкретные
классы, а просто перекрашивая токены цвета в одном месте. {% endcomment %} */
--color-link: var(--color-slate-500);
--color-link-hover: var(--color-indigo-800);
}
@layer base { /* -- СЛОЙ BASE для Tailwind v4.0+ см. https://tailwindcss.com/docs/layer-directive --{% comment %}
Типовой стиль ссылки "по умолчанию" для всего проекта.
Поэтому обычную текстовую ссылку теперь можно писать в шаблоне вообще без
class="..." (просто тег a с href и текстом внутри, без единого класса)
и она сразу получит фирменный вид сайта (пунктирное подчёркивание,
типовой цвет и ховер). Именно так сделано в block/breadcrumbs.html.
ВАЖНЫЙ НЮАНС для ссылок, которые НЕ должны выглядеть как "типовая
текстовая ссылка" (например, пункты меню-кнопок в block/header.html
там ссылка визуально оформлена как кнопка, а не как подчёркнутый текст):
Tailwind v4 использует настоящие CSS cascade layers (@layer base,
components, utilities...), и по их правилам слой `utilities` ВСЕГДА
побеждает слой `base` независимо от специфичности селектора НО только
для тех CSS-свойств, которые реально заданы в utilities-классе на этом
элементе. Если у пункта меню нет своего класса для color/border (как
сейчас в header.html там заданы только фон и отступы через
hover:bg-gray-200, px-4 и т.п.), то свойства color/border-bottom он
"унаследует" именно отсюда, а не из своего блочного оформления
родительский text-slate-900 на <menu> тут не поможет, т.к. у самого
<a> появится СВОЙ явный color.
Поэтому такие ссылки-кнопки должны явно "отключать" типовой стиль:
добавить border-0 (убирает пунктир) и свой text-slate-900 dark:text-
slate-100 (задаёт свой цвет вместо цвета типовой ссылки) это и сделано
в block/header.html.{% endcomment %} */
a {
@apply border-b border-dotted border-slate-500 dark:border-slate-300
text-(--color-link) dark:text-slate-300
hover:text-(--color-link-hover) dark:hover:text-indigo-200
hover:border-solid transition-colors;
}
}
@layer components { /* -- СЛОЙ COMPONENTS для компонентов и HTML-контента из БД -- {% comment %}
Стили для форматированного HTML-контента статей (из БД).
Поскольку HTML-текст статьи (s_article_content_html) приходит из CodeMirror-редактора
и с высокой вероятностью не содержит явных Tailwind-классов на каждом p, h2, ul, blockquote,
мы оборачиваем контент в шаблонный контейнер <div class="article-content">...</div>,
а здесь через @apply и каскадные селекторы задаем правила оформления элементов.
{% endcomment %} */
/* Контейнер форматированного текста статьи */
.article-content {
@apply text-slate-800 dark:text-slate-200 leading-relaxed text-base md:text-lg;
/* Заголовки внутри контента статьи */
h1, h2, h3, h4 {
@apply font-light text-slate-900 dark:text-slate-100 mt-8 mb-4 leading-snug;
}
h1 { @apply text-4xl md:text-5xl border-b border-slate-200 dark:border-slate-800 pb-2; }
h2 { @apply text-3xl md:text-4xl; }
h3 { @apply text-2xl md:text-3xl; }
h4 { @apply text-lg md:text-xl; }
h5 { @apply text-base md:text-lg text-slate-700 dark:text-slate-300; }
/* Параграфы и текстовые блоки */
p {
@apply my-4 leading-8;
}
/* Списки (маркированные и нумерованные) */
ul {
@apply list-disc list-outside space-y-2 pl-4 ml-4;
}
ol {
@apply list-decimal list-outside space-y-2 pl-4 ml-4;
}
li {
@apply leading-8;
}
/* Цитаты и выделенные блоки */
blockquote {
@apply border-l-4 border-indigo-500 bg-slate-100 dark:bg-slate-900/80
px-4 py-3 my-6 italic text-slate-700 dark:text-slate-300 rounded-r-lg;
}
/* Изображения внутри контента */
img {
@apply my-6 rounded-xl border border-slate-200 dark:border-slate-800 h-auto max-w-full mx-auto;
}
/* Инлайновый код и блоки кода */
code {
@apply bg-slate-200 dark:bg-slate-800 text-indigo-700 dark:text-indigo-300
px-1.5 py-0.5 rounded text-sm font-mono;
}
pre {
@apply bg-slate-900 text-slate-100 p-4 rounded-xl my-6 overflow-x-auto text-sm font-mono;
}
pre code {
@apply bg-transparent text-inherit p-0;
}
/* Горизонтальный разделитель */
hr {
@apply my-8 border-t border-slate-200 dark:border-slate-800;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
{% extends "_base.html" %}{% load static %}
{% block BODY %}{# ЭТО СПЛЕШ-СКРИН! ТУТ НЕ НИКАКОЙ НАВИГАЦИИ, МЕНЮ, ШАПКИ/ПОДВАЛА И Т.П. #}
<!-- HERO -->
<section class="relative w-full h-screen flex items-center justify-center overflow-hidden">
<!-- Фон из двух картинок -->
<div class="absolute inset-0 blur-[1px]">
<div class="absolute top-0 left-0 w-full h-1/2 bg-[url({% static 'svgs/lp.svg' %})] bg-bottom-right bg-no-repeat border-purple-950 border-b-1 border-dotted"></div>
<div class="absolute bottom-0 right-0 w-full h-1/2 bg-[url({% static 'svgs/cc.svg' %})] bg-bottom-right bg-no-repeat border-orange-950 border-b-1 border-dotted rotate-180"></div>
</div>
<!-- Содержимое поверх фона -->
<div class="relative z-10 text-center max-w-2xl mx-auto px-6 bg-[#c0c0c0e0] dark:bg-[#303030e0] shadow-lg shadow-gray-300 dark:shadow-gray-900 px-40 py-20 rounded-4xl border-gray-300 dark:border-gray-700 border-2">
<picture>
<source srcset="{% static 'svgs/logo-lpon-d.svg' %}" media="(prefers-color-scheme: dark)" class="h-32 mx-auto mb-8">
<img src="{% static 'svgs/logo-lpon-l.svg' %}" alt="Логотип lpon.ru" class="h-32 mx-auto mb-8">
</picture>
<h1 class="text-6xl font-bold mb-4 drop-shadow-lg font-serif">ДЕМО-РЕЖИМ</h1>
<p class="text-2xl mb-8 drop-shadow font-serif">Виниловые пластинки и&nbsp;не&nbsp;только…</p>
<a href="/catalog" class="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold drop-shadow-lg">
Посмотреть демо-каталог
</a>
</div>
<div class="absolute bottom-4 right-4 text-s text-gray-600 dark:text-gray-400 font-serif">
&copy; 2026, Sergei Erjemin
</div>
</section>{% endblock BODY %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "_base.html" %}{% load static %}
{% block PAGE_TITLE %}Инструкции — LPON.RU{% endblock %}
{% block DESCRIPTION %}Информационные и текстовые материалы, правила, условия и справка LPON.RU{% endblock %}
{% block CONTENT %}{# Подключение блока хлебных крошек #}{% include "block/breadcrumbs.html" %}
<div class="py-6">
{# Заголовок страницы #}
<h1 class="text-3xl font-bold mb-6 text-slate-900 dark:text-slate-100 px-4">
Инструкции
</h1>
{# Список статей #}
{% if articles %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% for article in articles %}
<article class="flex flex-col bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-5 hover:shadow-md transition-shadow">
{# Изображение обложки, если есть #}
{% if article.k_article_to_image %}
<div class="mb-4 overflow-hidden rounded-lg max-h-48 flex items-center justify-center bg-slate-200 dark:bg-slate-800">
<img
src="{{ article.k_article_to_image.url }}"
alt="{{ article.k_article_to_image.default_alt_text|default:article.s_article_title }}"
class="w-full h-auto object-cover"
/>
</div>
{% endif %}
{# Заголовок статьи со ссылкой #}
<h2 class="text-xl font-semibold mb-2 text-slate-900 dark:text-slate-100">
<a href="/info/{{ article.slug }}/" class="text-slate-900 dark:text-slate-100 hover:text-blue-500">
{% if article.s_article_title_html %}
{{ article.s_article_title_html|safe }}
{% else %}
{{ article.s_article_title }}
{% endif %}
</a>
</h2>
{# Тизер / анонс статьи #}
{% if article.s_article_teaser_html %}
<div class="text-slate-600 dark:text-slate-400 mb-4 grow leading-relaxed">
{{ article.s_article_teaser_html|safe }}
</div>
{% endif %}
{# Подвал карточки с датой и кнопкой перехода #}
<div class="mt-auto pt-4 border-t border-slate-200/60 dark:border-slate-800 flex items-center justify-between text-sm text-slate-500 dark:text-slate-40">
<span>{{ article.t_article_created|date:"Y.m.d" }}</span>
<a href="/info/{{ article.slug }}/" class="font-medium">
Читать полностью &rarr;
</a>
</div>
</article>
{% endfor %}
</div>
{% else %}
{# Сообщение, если опубликованных статей типа TXT пока нет #}
<div class="p-8 text-center bg-slate-50 dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800">
<p class="text-slate-600 dark:text-slate-400">
Пока нет опубликованных текстовых материалов.
</p>
</div>
{% endif %}
</div>
{% endblock CONTENT %}
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Политика безопасности и конфиденциальности LPON.RU</title>
</head>
<body>
<h1>Политика безопасности и конфиденциальности LPON.RU</h1>
<div class="intro-box">
<p>Привет! Рады видеть вас в магазине аналогового звука и виниловых пластинок <strong>LPON.RU</strong>. Мы глубоко ценим доверие сообщества любителей музыки и придерживаемся принципа максимальной открытости и безусловного уважения к приватности. Настоящий документ объясняет, как мы обрабатываем заказы, соблюдаем правила защиты данных и почему наш сайт спроектирован так, чтобы не хранить о вас ничего лишнего.</p>
</div>
<h2>1. Архитектура Stateless Orders: Мы не храним ваши данные в БД<small>(Главный принцип работы нашего магазина)</small></h2>
<p>В отличие от большинства интернет-магазинов, LPON.RU работает по архитектуре <strong>Stateless Orders</strong> («заказы без сохранения состояния»). На наших серверах полностью отсутствует классическая база данных клиентов или заказов.</p>
<p>Когда вы оформляете заказ на пластинки или технику:</p>
<ul>
<li>Ваши персональные данные (имя, адрес доставки, контакты) обрабатываются в оперативной памяти сервера только в момент формирования заказа.</li>
<li>Сформированный заказ упаковывается в зашифрованный signed-токен и передается напрямую в почтовый шлюз для уведомления вас и оператора доставки.</li>
<li>После отправки письма персональная информация мгновенно и безвозвратно удаляется из оперативной памяти сервера. На сервере не остается никаких персональных записей, истории покупок или профилей пользователей.</li>
</ul>
<h2>2. Какие технические данные собирает LPON.RU?<br><small>(Минимум, необходимый для стабильности и безопасности)</small></h2>
<p>Чтобы поддерживать корректное отображение каталога, защищать сайт от спама и DOS-атак, а также анализировать нагрузку, мы собираем исключительно агрегированную и обезличенную техническую информацию:</p>
<ul>
<li>Общее количество просмотров страниц и карточек товаров.</li>
<li>Агрегированные данные о запросах к каталогу (без привязки к конкретным пользователям).</li>
<li>Крассрочные (до 3 дней) технические логи веб-сервера (nginx) для выявления злонамеренной активности и обеспечения сетевой безопасности.</li>
</ul>
<p>Эти данные не содержат ФИО, адресов, email или номеров телефонов и не позволяют идентифицировать вас как конкретное физическое лицо.</p>
<h2>3. Сторонние сервисы аналитики<br><small>(Только с вашего явного согласия)</small></h2>
<p>Если вы дали согласие на использование аналитических файлов cookie на информационном баннере сайта, мы можем использовать стандартные веб-метрики для изучения работы интерфейса:</p>
<ul>
<li>Яндекс.Метрика</li>
<li>Google Analytics</li>
</ul>
<p>Указанные сервисы могут обрабатывать обезличенные технические параметры визита (IP-адрес, тип браузера, разрешение экрана) в соответствии со своими политиками конфиденциальности. Содержимое ваших заказов, корзины и личные данные в аналитические сервисы <strong>никогда не передаются</strong>.</p>
<h2>4. Безопасность и правила использования</h2>
<p>Мы принимаем все необходимые организационные и технические меры для защиты инфраструктуры LPON.RU. Обращаем внимание, что на нашем сервере физически отсутствуют пользовательские базы данных, сохраненные пароли, платежные реквизиты или архивы персональных данных.</p>
<p>Попытки атак, автоматизированного скрапинга с чрезмерной нагрузкой или блокировки обслуживания вредят другим ценителям винила. В случае обнаружения вредоносной активности мы оставляем за собой право блокировать IP-адреса нарушителей и передавать данные провайдерам в соответствии с законодательством.</p>
<h2>5. Отказ от ответственности</h2>
<p>Сервис LPON.RU предоставляется по принципу «как есть» (as is). Мы прилагаем максимальные усилия для точного отображения наличия и состояния товаров, но не несем ответственности за косвенные убытки, возникшие в результате временной недоступности сайта или сбоев в сторонних каналах связи.</p>
<h2>6. Обратная связь и контакты</h2>
<p>Если у вас возникли вопросы по работе магазина, состоянию виниловых пластинок или нашей политике конфиденциальности, вы всегда можете связаться с нами через официальный репозиторий проекта или по электронной почте:</p>
<ul>
<li>Gitea Issues: <span class="badge">git.cube2.ru/erjemin/2018-lpon-site</span></li>
<li>Email для связи: <span class="badge">support@lpon.ru</span></li>
</ul>
<div class="footer-note">
© LPON.RU, 2026. Проект магазина аналоговой музыки и звукового оборудования.
</div>
</body>
</html>
"""
with open("lpon_privacy_policy.html", "w", encoding="utf-8") as f:
f.write(html_content)
from weasyprint import HTML
HTML("lpon_privacy_policy.html").write_pdf("lpon_privacy_policy.pdf")
print("PDF created successfully: lpon_privacy_policy.pdf")
```
<h3>1. Архитектура Stateless Orders: Мы не храним ваши данные на сервере</h3>
<p>В отличие от большинства интернет-магазинов, LPON.RU работает по архитектуре **Stateless Orders** («заказы без сохранения состояния»). На наших серверах полностью отсутствует классическая база данных клиентов или заказов.</p>.
<p><strong>Это главный принцип работы нашего магазина</strong>:</p>
<p>Когда вы оформляете заказ на пластинки или технику:</p>
<ul>
<li>Ваши персональные данные (имя, адрес доставки, контакты) обрабатываются в виджетах на стороне процессинга и логистической компании. Они не попадают даже в оперативную память сервера даже в момент формирования заказа.</li>
<li>Сформированный заказ упаковывается в зашифрованный signed-токен и передается напрямую в почтовый шлюз для уведомления вас и оператора доставки.</li>
<li>После отправки письма информация мгновенно и безвозвратно удаляется. На сервере не остается никаких записей, истории покупок или профилей пользователей. Мы храним только обезличенный токен вашего заказа и его состав. Любые разбирательства по составу заказа и доставке доступны только при предоставлении токена с вашей стороны.</li>
</ul>
<hr />
<h3>2. Какие технические данные собирает LPON.RU?</h3>
<p><strong>Минимум, необходимый для стабильности и безопасности</strong></p>
<p>Чтобы поддерживать корректное отображение каталога, защищать сайт от спама и DOS-атак, а также анализировать нагрузку, мы собираем исключительно агрегированную и обезличенную техническую информацию:</p>
<ul>
<li>Общее количество просмотров страниц и карточек товаров.</li>
<li>Агрегированные данные о запросах к каталогу (без привязки к конкретным пользователям или IP-адресам).</li>
<li>Краткосрочные (до 3 дней) технические логи веб-сервера (nginx) для выявления злонамеренной активности и обеспечения сетевой безопасности.</li>
</ul>
<p><strong>Эти данные не содержат ФИО, адресов, email или номеров телефонов и не позволяют идентифицировать вас как конкретное физическое лицо.</strong></p>
<hr />
<h3>3. Сторонние сервисы аналитики</h3>
<p><em>Только с вашего явного согласия</em></p>
<p>Если вы дали согласие на использование аналитических файлов cookie на информационном баннере сайта (он появляется снизу поверх контента), мы можем использовать стандартные веб-метрики для изучения работы интерфейса:</p>
<ul>
<li>Яндекс.Метрика</li>
<li>Google Analytics</li>
</ul>
<p>Указанные сервисы могут обрабатывать обезличенные технические параметры визита (IP-адрес, тип браузера, разрешение экрана) в соответствии со своими политиками конфиденциальности. Можете познакомиться с ними по ссылкам:</p>
<ul>
<li><a href="https://yandex.ru/legal/metrika/confidentiality/" target="_blank">Яндекс.Метрика</a></li>
<li><a href="https://policies.google.com/privacy" target="_blank">Google Analytics</a></li>
</ul>
<p>Содержимое ваших заказов, корзины и личные данные защищены от индексации и в аналитические сервисы <strong>никогда и никуда не передаются</strong>.</p>
<hr />
<h3>4. Безопасность и правила использования</h3>
<p>Мы принимаем все необходимые организационные и технические меры для защиты инфраструктуры LPON.RU. Обращаем внимание, что на нашем сервере физически отсутствуют пользовательские базы данных, логины/пароли, платежные реквизиты или архивы персональных данных.</p>
<p>Попытки атак, автоматизированного скрапинга с чрезмерной нагрузкой или блокировки обслуживания вредят другим ценителям винила, кассет и теплого аналогового звука. В случае обнаружения вредоносной активности мы оставляем за собой право блокировать IP-адреса нарушителей и передавать данные провайдерам и службам противодействия атакам (такими как <a href="https://crowdsec.net/" target="_blank">CrowdSec</a> или подобным).</p>
<hr />
<h3>5. Отказ от ответственности</h3>
<p>Сервис LPON.RU предоставляется по принципу «как есть» (as is). Мы прилагаем максимальные усилия для точного отображения наличия и состояния товаров, но не несем ответственности за косвенные убытки, возникшие в результате временной недоступности сайта или сбоев в сторонних каналах связи.</p>
<hr />
<h3>6. Обратная связь и контакты</h3>
<p>Если у вас возникли вопросы по работе магазина, состоянию виниловых пластинок или нашей политике конфиденциальности, вы всегда можете связаться с нами через официальный репозиторий проекта или по электронной почте:</p>
<ul>
<li><strong>Gitea Issues:</strong> <code><a href="https://git.cube2.ru/erjemin/2018-lpon-site" target="_blank">https://git.cube2.ru/erjemin/2018-lpon-site</a></code></li>
<li><strong>Email:</strong> <code>support@lpon.ru</code></li>
</ul>
</body>
</html>
Generated
+36 -1
View File
@@ -314,6 +314,20 @@ beautifulsoup4 = ">=4.10.0"
lxml = ">=4.9.0" lxml = ">=4.9.0"
regex = ">=2022.1.18" regex = ">=2022.1.18"
[[package]]
name = "hashids"
version = "1.3.1"
description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/"
optional = false
python-versions = ">=2.7"
files = [
{file = "hashids-1.3.1-py2.py3-none-any.whl", hash = "sha256:8bddd1acba501bfc9306e7e5a99a1667f4f2cacdc20cbd70bcc5ddfa5147c94c"},
{file = "hashids-1.3.1.tar.gz", hash = "sha256:6c3dc775e65efc2ce2c157a65acb776d634cb814598f406469abef00ae3f635c"},
]
[package.extras]
test = ["pytest (>=2.1.0)"]
[[package]] [[package]]
name = "lxml" name = "lxml"
version = "6.1.1" version = "6.1.1"
@@ -637,6 +651,27 @@ docs = ["sphinx (>=4.4)", "sphinx-issues (>=3.0.1)", "sphinx-rtd-theme (>=1.0)"]
tests = ["defusedxml", "numpy", "packaging", "pympler", "pytest"] tests = ["defusedxml", "numpy", "packaging", "pympler", "pytest"]
tests-min = ["defusedxml", "packaging", "pytest"] tests-min = ["defusedxml", "packaging", "pytest"]
[[package]]
name = "pyrefly"
version = "1.2.0"
description = "A fast type checker and language server for Python with powerful IDE features"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e"},
{file = "pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d"},
{file = "pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d"},
{file = "pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd"},
{file = "pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a"},
{file = "pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1"},
{file = "pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf"},
{file = "pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe"},
{file = "pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa"},
{file = "pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170"},
{file = "pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e"},
{file = "pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185"},
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -903,4 +938,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "69bad093bc7511b8da08bb4673d5883f7cbeb9b45123b6a17350dd885e2a3a33" content-hash = "d67d5a40db0361dc1f1d6b4a0ad45921d093c0335b0fdcc6c5159ccfd9ae6d19"
@@ -57,6 +57,9 @@
} }
/* --- Новый класс для минимальной высоты --- */ /* --- Новый класс для минимальной высоты --- */
.codemirror-min-height-2 .cm-editor {
min-height: 3.5em; /* 5 строк */
}
.codemirror-min-height-5 .cm-editor { .codemirror-min-height-5 .cm-editor {
min-height: 8em; /* 5 строк */ min-height: 8em; /* 5 строк */
} }
+10
View File
@@ -0,0 +1,10 @@
/*
lpon.css обычный (не Tailwind-специфичный) CSS сайта lpon.ru
(c) 2026, Sergei Erjemin, https://lpon.ru
ВАЖНО: этот файл подключается напрямую через <link rel="stylesheet">
и НЕ проходит через компилятор Tailwind.
Tailwind-директивы (@theme, @layer, @apply) здесь работать НЕ БУДУТ!
Сейчас таких стилей нет, файл пуст.
*/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+98
View File
@@ -0,0 +1,98 @@
// analytics.js — Аналитика и счетчики посещений для oknardia.ru
// Версия: 1.0 | Дата: 2026-05-15
// Содержит: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru
(function() {
'use strict';
// ============================================================================
// Google Analytics 4 (GA4)
// ID: GT-XXXXXXXX
// ============================================================================
(function() {
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=GT-XXXXXXXX';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', 'GT-XXXXXXXX');
})();
// ============================================================================
// Yandex.Metrika (Яндекс.Метрика)
// ID: 12345678
// ============================================================================
(function() {
window.ym = window.ym || function(){
(window.ym.a = window.ym.a || []).push(arguments);
};
window.ym.l = 1 * new Date();
// Загружаем скрипт Метрики
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'https://mc.yandex.ru/metrika/tag.js';
document.head.appendChild(script);
// Инициализируем Метрику
window.ym(12345678, 'init', {
trackHash: true,
clickmap: true,
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true
});
})();
// ============================================================================
// Top.Mail.Ru counter (Рейтинг@Mail.ru)
// ID: 1234567
// ============================================================================
(function() {
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({
id: "1234567",
type: "pageView",
start: (new Date()).getTime()
});
(function(d, w, id) {
if (d.getElementById(id)) return;
var ts = d.createElement("script");
ts.type = "text/javascript";
ts.async = true;
ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function() {
var s = d.getElementsByTagName("script")[0];
s.parentNode.insertBefore(ts, s);
};
if (w.opera == "[object Opera]") {
d.addEventListener("DOMContentLoaded", f, false);
} else {
f();
}
})(document, window, "tmr-code");
// Добавляем изображение для noscript
if (!window.noScriptAdded) {
window.noScriptAdded = true;
var noscriptDiv = document.createElement('div');
noscriptDiv.style.display = 'none';
noscriptDiv.innerHTML = '<img src="https://top-fwz1.mail.ru/counter?id=1234567;js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" />';
document.body.appendChild(noscriptDiv);
}
})();
})();
// Экспортируем gtag в глобальный контекст для возможности использования в коде
// window.gtag доступна через глобальную переменную
+81 -5
View File
@@ -14,6 +14,10 @@
* 4. Визуально показывает пользователю статус обхода валидации цветом кнопок * 4. Визуально показывает пользователю статус обхода валидации цветом кнопок
* *
* Универсальное решение: работает для любых форм в админке, не только для лейблов. * Универсальное решение: работает для любых форм в админке, не только для лейблов.
*
* ВАЖНО: Этот скрипт работает независимо от порядка загрузки и инициализации CodeMirror.
* Используется стратегия проверки readyState и MutationObserver для динамического отслеживания
* появления новых элементов, включая CodeMirror div.codemirror.
*/ */
// Функция для добавления GET параметра к form action кнопки // Функция для добавления GET параметра к form action кнопки
@@ -42,7 +46,8 @@ function markSubmitButtonsToIgnoreValidation() {
}); });
} }
document.addEventListener('DOMContentLoaded', function () { // ===== ОСНОВНАЯ ФУНКЦИЯ ИНИЦИАЛИЗАЦИИ ВОТЧЕРА =====
function initFormWatcher() {
// Находим все submit-кнопки администратора // Находим все submit-кнопки администратора
let submitButtons = document.querySelectorAll('input[type=submit]'); let submitButtons = document.querySelectorAll('input[type=submit]');
@@ -80,6 +85,10 @@ document.addEventListener('DOMContentLoaded', function () {
} }
// Отслеживаем изменения всех типов полей в форме // Отслеживаем изменения всех типов полей в форме
// ВАЖНО: Используем MutationObserver для динамического отслеживания появления CodeMirror элементов
let watchedInputs = new WeakSet();
function attachWatchersToInputs() {
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]'); let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
// Функция которая срабатывает при любом изменении // Функция которая срабатывает при любом изменении
@@ -104,15 +113,23 @@ document.addEventListener('DOMContentLoaded', function () {
} }
formInputs.forEach(function (input) { formInputs.forEach(function (input) {
// Избегаем двойного присоединения слушателей (используем WeakSet)
if (!watchedInputs.has(input)) {
watchedInputs.add(input);
// Слушаем оба события: 'change' для обычных input/select // Слушаем оба события: 'change' для обычных input/select
// и 'input' для CodeMirror и других редакторов // и 'input' для CodeMirror и других редакторов
input.addEventListener('change', handleChange); input.addEventListener('change', handleChange);
input.addEventListener('input', handleChange); input.addEventListener('input', handleChange);
}
}); });
}
// Первый раз присоединяем слушателей к уже существующим элементам
attachWatchersToInputs();
// Мониторим изменение класса force-ignore-validation на кнопках // Мониторим изменение класса force-ignore-validation на кнопках
// Используем MutationObserver для отслеживания добавления/удаления класса // Используем MutationObserver для отслеживания добавления/удаления класса
const observer = new MutationObserver(function (mutations) { const classObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) { mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') { if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
let btn = mutation.target; let btn = mutation.target;
@@ -130,8 +147,67 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
}); });
// Настраиваем observer для отслеживания изменения класса // Настраиваем observer для отслеживания изменения класса на кнопках
submitButtons.forEach(function (btn) { submitButtons.forEach(function (btn) {
observer.observe(btn, {attributes: true, attributeFilter: ['class']}); classObserver.observe(btn, {attributes: true, attributeFilter: ['class']});
}); });
});
// ===== ДИНАМИЧЕСКОЕ ОТСЛЕЖИВАНИЕ ПОЯВЛЕНИЯ НОВЫХ ЭЛЕМЕНТОВ (особенно CodeMirror) =====
// Используем MutationObserver для отслеживания появления новых элементов формы
// Это нужно на случай если CodeMirror инициализируется ПОСЛЕ загрузки скрипта
const formObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
// Проверяем появились ли новые узлы (добавлены новые элементы)
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Проверяем есть ли среди добавленных элементов CodeMirror div или input/textarea
let hasNewFormElements = false;
mutation.addedNodes.forEach(function (node) {
if (node.nodeType === 1) { // Node.ELEMENT_NODE
if (node.classList && (node.classList.contains('codemirror') ||
node.tagName === 'INPUT' ||
node.tagName === 'TEXTAREA' ||
node.tagName === 'SELECT')) {
hasNewFormElements = true;
}
// Проверяем в потомках
if (node.querySelector && (node.querySelector('.codemirror') ||
node.querySelector('input:not([type=submit])') ||
node.querySelector('textarea') ||
node.querySelector('select'))) {
hasNewFormElements = true;
}
}
});
// Если нашли новые элементы формы, повторно присоединяем слушателей
if (hasNewFormElements) {
attachWatchersToInputs();
}
}
});
});
// Настраиваем observer для отслеживания изменений в форме (появления новых элементов)
formObserver.observe(form, {childList: true, subtree: true});
}
// ===== ИНИЦИАЛИЗАЦИЯ: Работает в любых условиях (до или после DOMContentLoaded) =====
// Стратегия:
// 1. Если document.readyState === 'loading', ждем DOMContentLoaded
// 2. Если document.readyState === 'interactive' или 'complete', запускаем сразу
// 3. Используем requestIdleCallback если доступен, иначе setTimeout
if (document.readyState === 'loading') {
// DOM еще загружается, ждем события
document.addEventListener('DOMContentLoaded', initFormWatcher);
} else {
// DOM уже загружен (скрипт загрузился после DOMContentLoaded)
// Запускаем инициализацию асинхронно чтобы все элементы успели загрузиться
if (typeof requestIdleCallback !== 'undefined') {
// Используем requestIdleCallback для загрузки в свободное время браузера
requestIdleCallback(initFormWatcher, {timeout: 2000});
} else {
// Fallback: используем setTimeout с минимальной задержкой
setTimeout(initFormWatcher, 0);
}
}
+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M341.8 72.6c-12.3-11.4-31.3-11.4-43.5 0l-224 208c-9.6 9-12.8 22.9-8 35.1S82.8 336 96 336h16v176c0 35.3 28.7 64 64 64h288c35.3 0 64-28.7 64-64V336h16c13.2 0 25-8.1 29.8-20.3s1.6-26.2-8-35.1l-224-208zM304 384h32c26.5 0 48 21.5 48 48v96H256v-96c0-26.5 21.5-48 48-48z"/></svg>

After

Width:  |  Height:  |  Size: 343 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

+2
View File
@@ -17,6 +17,8 @@ django-filer = "^3.4.4"
pillow-heif = "^1.3.0" pillow-heif = "^1.3.0"
pytils = "^0.4.4" pytils = "^0.4.4"
etpgrf = "^0.1.6.post1" etpgrf = "^0.1.6.post1"
hashids = "^1.3.1"
pyrefly = ">=0.60.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
django-extensions = "^3.2" django-extensions = "^3.2"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Сборка Alpine.js для фронтенда LPON.RU
# Запуск из корня проекта: bash ./scripts/build-alpine.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ALPINE_DIR="$PROJECT_ROOT/frontend-assembly/alpine"
OUTPUT_DIR="$PROJECT_ROOT/public/static/js"
log() {
printf '[alpine] %s\n' "$*"
}
fail() {
printf '[alpine] %s\n' "$*" >&2
exit 1
}
cleanup() {
rm -rf "$ALPINE_DIR/src" "$ALPINE_DIR/node_modules"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$ALPINE_DIR/package.json" ]]; then
fail "Не найден package.json: $ALPINE_DIR/package.json"
fi
if [[ ! -f "$ALPINE_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $ALPINE_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
log "Создаю entry point для Alpine.js"
# src/alpine.js (entry point)
mkdir -p "$ALPINE_DIR/src"
cat > "$ALPINE_DIR/src/alpine.js" <<'EOF'
import Alpine from 'alpinejs';
Alpine.start();
export default Alpine;
EOF
log "СОБИРАЮ Alpine.js"
cd "$ALPINE_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю JavaScript'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/alpine.min.js"
+21 -28
View File
@@ -1,17 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Скрипт создаёт временную рабочую папку, ставит зависимости через `npm ci`, собирает # Сборка CodeMirror 6 для админки Django
# минимизированный бандл и затем сам удаляет временные `src/` и `node_modules/`. # Запуск из корня проекта: bash ./scripts/build-codemirror.sh
# В проекте остаётся только готовая статика:
# * `public/static/codemirror/editor.js`
#
# Запуск:
# bash ./frontend-assembly/build-codemirror6.sh
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUTPUT_DIR="$ROOT_DIR/../public/static/codemirror" CODEMIRROR_DIR="$PROJECT_ROOT/frontend-assembly/codemirror"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codemirror6.XXXXXX")" OUTPUT_DIR="$PROJECT_ROOT/public/static/codemirror"
log() { log() {
printf '[codemirror6] %s\n' "$*" printf '[codemirror6] %s\n' "$*"
@@ -22,29 +17,25 @@ fail() {
exit 1 exit 1
} }
cleanup() {
rm -rf "$WORK_DIR"
rm -rf "$ROOT_DIR/src" "$ROOT_DIR/node_modules"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.' fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi fi
if [[ ! -f "$ROOT_DIR/package.json" ]]; then if [[ ! -f "$CODEMIRROR_DIR/package.json" ]]; then
fail "Не найден package.json: $ROOT_DIR/package.json" fail "Не найден package.json: $CODEMIRROR_DIR/package.json"
fi fi
if [[ ! -f "$ROOT_DIR/package-lock.json" ]]; then if [[ ! -f "$CODEMIRROR_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $ROOT_DIR/package-lock.json" fail "Не найден package-lock.json: $CODEMIRROR_DIR/package-lock.json"
fi fi
mkdir -p "$WORK_DIR/src" "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
cp "$ROOT_DIR/package.json" "$ROOT_DIR/package-lock.json" "$WORK_DIR/"
cat > "$WORK_DIR/src/editor.js" <<'EOF' # Создаём src/editor.js для CodeMirror
mkdir -p "$CODEMIRROR_DIR/src"
log "Создаю src/editor.js в $CODEMIRROR_DIR"
cat > "$CODEMIRROR_DIR/src/editor.js" <<'EOF'
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, lineNumbers, placeholder } from '@codemirror/view'; import { EditorView, lineNumbers, placeholder } from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
@@ -169,9 +160,8 @@ if (document.readyState === 'loading') {
} }
EOF EOF
log "СОБИРАЮ CodeMirror 6 ДЛЯ ФРОНТЕНДА АДМИНКИ ПРОЕКТА" log "СОБИРАЮ CodeMirror 6"
log "Временная рабочая папка: $WORK_DIR" cd "$CODEMIRROR_DIR"
cd "$WORK_DIR"
log 'Устанавливаю зависимости через npm ci' log 'Устанавливаю зависимости через npm ci'
npm ci npm ci
@@ -180,4 +170,7 @@ log 'Собираю CodeMirror 6'
export CM6_OUTPUT_DIR="$OUTPUT_DIR" export CM6_OUTPUT_DIR="$OUTPUT_DIR"
npm run build npm run build
log 'ГОТОВО' log 'Удаляю временные файлы (src/ и node_modules/)'
rm -rf src node_modules
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/editor.js"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Сборка HTMX для фронтенда LPON.RU
# Запуск из корня проекта: bash ./scripts/build-htmx.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HTMX_DIR="$PROJECT_ROOT/frontend-assembly/htmx"
OUTPUT_DIR="$PROJECT_ROOT/public/static/js"
log() {
printf '[htmx] %s\n' "$*"
}
fail() {
printf '[htmx] %s\n' "$*" >&2
exit 1
}
cleanup() {
rm -rf "$HTMX_DIR/src" "$HTMX_DIR/node_modules"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$HTMX_DIR/package.json" ]]; then
fail "Не найден package.json: $HTMX_DIR/package.json"
fi
if [[ ! -f "$HTMX_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $HTMX_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
log "Создаю entry point для HTMX"
# src/htmx.js (entry point)
mkdir -p "$HTMX_DIR/src"
cat > "$HTMX_DIR/src/htmx.js" <<'EOF'
import htmx from 'htmx.org';
window.htmx = htmx;
export default htmx;
EOF
log "СОБИРАЮ HTMX"
cd "$HTMX_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю JavaScript'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/htmx.min.js"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Сборка Tailwind CSS для фронтенда LPON.RU
# Запуск из корня проекта: bash ./scripts/build-tailwind.sh
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TAILWIND_DIR="$PROJECT_ROOT/frontend-assembly/tailwind"
OUTPUT_DIR="$PROJECT_ROOT/public/static/css"
log() {
printf '[tailwind] %s\n' "$*"
}
fail() {
printf '[tailwind] %s\n' "$*" >&2
exit 1
}
# Скрипт идемпотентный и не оставляет после себя рабочих файлов: input.css
# генерируется заново при каждом запуске (см. ниже), а node_modules ставится
# через `npm ci` (чистая установка по package-lock.json). Поэтому ни один из
# этих файлов не хранится в репозитории и не добавлен в .gitignore отдельно -
# cleanup() гарантированно подчищает их за собой при любом завершении скрипта
# (успех, ошибка, Ctrl+C). Единственное исключение - если процесс убьют
# принудительно (kill -9/SIGKILL), тогда trap не сработает и файлы останутся
# на диске; в этом случае их можно смело удалить руками либо просто повторно
# запустить скрипт - он всё перезапишет.
cleanup() {
rm -rf "$TAILWIND_DIR/src" "$TAILWIND_DIR/node_modules" "$TAILWIND_DIR/tailwind.config.js" "$TAILWIND_DIR/input.css"
}
trap cleanup EXIT INT TERM
if ! command -v npm >/dev/null 2>&1; then
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$TAILWIND_DIR/package.json" ]]; then
fail "Не найден package.json: $TAILWIND_DIR/package.json"
fi
if [[ ! -f "$TAILWIND_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $TAILWIND_DIR/package-lock.json"
fi
mkdir -p "$OUTPUT_DIR"
log "Создаю entry point для Tailwind"
# input.css (entry point для Tailwind CLI).
# ВАЖНО: этот файл перезаписывается безусловно при КАЖДОМ запуске скрипта
# (не только если его нет) - он одноразовый, генерируется прямо здесь
# через heredoc и удаляется в конце работы скрипта через cleanup()
# (см. начало файла). Поэтому input.css не хранится в репозитории и
# его не нужно редактировать руками - любые правки нужно вносить
# в текст heredoc ниже, иначе они потеряются при следующей сборке.
# ВАЖНО: Tailwind так устроен, что он собирает в CSS только те классы, которые реально используются в исходниках (HTML,
# Python и т.д.) Это очень круто, т.к. финальный CSS проекта будет минимальным по размеру!
# Чтобы Tailwind знал, где искать классы, нужно указать ему исходники через директиву @source в input.css
# (см. подробный комментарий прямо над самими директивами @source внутри heredoc ниже).
cat > "$TAILWIND_DIR/input.css" <<'EOF'
/*
input.css — точка входа для сборки prod-версии Tailwind CSS (Tailwind CLI).
Собирается командой `npm run build` (см. package.json в этой же папке) в
файл public/static/css/tailwind.min.css, который подключается в _base.html
для production (когда settings.DEBUG == False).
Здесь же подключаются кастомные @theme/@layer директивы проекта из общего
файла lpon_site/templates/css/tailwind-custom.css — того же самого файла,
который в dev-режиме подключается через {% include %} внутрь инлайнового
<style type="text/tailwindcss"> в _base.html. Это обеспечивает единый
источник кастомных стилей для dev и prod
*/
@import "tailwindcss";
@import "../../lpon_site/templates/css/tailwind-custom.css";
/* Директива @source указывает Tailwind, ГДЕ искать реально используемые
классы (см. общее пояснение про @source выше, перед heredoc). Пути ниже
заданы относительно этого файла (frontend-assembly/tailwind/input.css),
поэтому "../../" ведёт в корень проекта. Если появятся новые каталоги с
разметкой/классами (например, отдельное приложение или папка partials
за пределами уже перечисленных путей), их нужно будет добавить сюда
отдельной строкой @source - иначе Tailwind не увидит использованные в
них классы и просто не включит их в итоговый tailwind.min.css. */
@source "../../lpon_site/templates/**/*.html";
@source "../../lpon_site/frontend/**/*.py";
EOF
log "СОБИРАЮ Tailwind CSS"
cd "$TAILWIND_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю CSS'
npm run build
log 'ГОТОВО! Результат: '"$OUTPUT_DIR/tailwind.min.css"