116 Commits

Author SHA1 Message Date
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
erjemin 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
erjemin 3928066147 minor: аннотирование типов 2026-06-30 17:48:47 +03:00
erjemin 64880da11a add: универсальная хелпер-функция для save() моделей для создания (и проверки) связанных статей в TbArticle. 2026-06-30 16:41:42 +03:00
erjemin be542e37ab mod: модели (10) создание связанное статьи в TbLabel в методе save() вынесено в универсальную хелпер-функцию. 2026-06-30 16:39:50 +03:00
erjemin 08bfce2c4c mod: админка (15) для Лейблов LabelAdminForm и LabelAdmin добавлен кастомный save_model (вывод после добавления/изменения записи) и универсальная хелпер-функция. + микро-хелпер для описания связанной статьи. 2026-06-29 21:15:16 +03:00
erjemin 01779a89f0 mod: админка (14) для Лейблов LabelAdminForm и LabelAdmin добавлен кастомный save_model (вывод после добавления/изменения записи) и универсальная хелпер-функция. 2026-06-29 21:02:40 +03:00
erjemin 13ad08e294 mod: валидатор форм, парсера и моделей (14) формирование отчетов о совпадениях вынесен в отдельную функцию build_search_report. Еще больше улучшен отчет валидатора для случаев совпадений и "схожести" 2026-06-29 14:53:02 +03:00
erjemin b316efc425 mod: валидатор форм, парсера и моделей (14) формирование отчетов о совпадениях вынесен в отдельную функцию build_search_report. Еще больше улучшен отчет валидатора для случаев совпадений и "схожести" 2026-06-29 14:52:31 +03:00
erjemin 7b623e975d mod: валидатор форм, парсера и моделей (13) улучшен отчет валидатора для случаев "схожести текущей записи и синонимов с синонимами других записей" 2026-06-29 13:30:37 +03:00
erjemin 01710200b2 minor: улучшена читаемость кода 2026-06-26 18:21:55 +03:00
erjemin d53330554b mod: валидатор форм, парсера и моделей (12) валидатор для случаев "схожести текущей записи и синонимов с синонимами других записей" 2026-06-26 17:54:52 +03:00
erjemin 8ed61bae13 mod: валидатор форм, парсера и моделей (11) валидатор для случая "синонимы в текущей записи совпадают с синонимами других записей" 2026-06-24 21:46:15 +03:00
erjemin c7844aa3d7 mod: валидатор форм, парсера и моделей (10) валидатор в save() модели поднимает ValidationError если есть дубли в синонимах других записей. Временное решение пока нет брокера сообщений и парсера. 2026-06-23 16:13:20 +03:00
erjemin 48df2fb32b mod: валидатор форм, парсера и моделей (09) уделение дублей в синонимах других записей при редактировании через админку 2026-06-23 15:47:46 +03:00
erjemin 562b58be47 mod: валидатор форм, парсера и моделей (08) избежания дублей в синонимах других записей через админку 2026-06-22 18:52:24 +03:00
erjemin 5b339d1a53 mod: валидатор форм, парсера и моделей (07) избежания дублей в синонимах других записей 2026-06-22 17:38:04 +03:00
erjemin 73f0c615f0 minor: беджи 2026-06-22 15:33:09 +03:00
erjemin 8b3fe1fe41 tmp: валидатор форм, парсера и моделей (06) борьба с интерфейсом 2026-06-21 17:11:54 +03:00
erjemin e970d59173 mod: валидатор форм, парсера и моделей (05) избежания дублей в синонимах других записей. 2026-06-21 15:31:41 +03:00
erjemin a30d7f5483 mod: модели (09) работа с синонимами вынесена в хелпер внутри utils.py. Подключен в модели TbLabel в метод save() . 2026-06-20 21:03:21 +03:00
erjemin afef6b639c add: валидатор форма, парсера и моделей (4) избежания дублей при вызове save() модели в обход админки и парсера 2026-06-20 20:32:39 +03:00
erjemin eceeaaa55b add: валидатор форм для избежания дублей (3) относительная ссылка в админке 2026-06-20 19:47:24 +03:00
erjemin 6a178c9c9b add: валидатор форм для избежания дублей (2) оформлено в хелпер 2026-06-20 18:37:24 +03:00
erjemin 3d9f0a6b5d add: валидатор для избежания дублей (1) полные дубли 2026-06-20 17:58:31 +03:00
erjemin 80fef9791c mod: модели (08) для TbLabel проверяем и прописываем синонимы в мата-данных в j_label_metadata при создани и изменении названия лейбла. 2026-06-18 21:05:57 +03:00
erjemin 0c3c7bc9ac mod: модели (07) применен make_slug для слагов, в модели TbLabel переопределен метод save() чтобы если не указана связанная статья в TbArticle, она создалась автоматически (обязательно). 2026-06-15 22:15:31 +03:00
erjemin 6d69cd1d41 minor: убран потенциальный конфликт в хелпере make_slug 2026-06-15 19:52:07 +03:00
erjemin 4a24037ae8 add: хелперы: make_slug (создание слага) и safe_html_special_symbols (для очистки от html и мнемоник) 2026-06-15 19:37:33 +03:00
erjemin 96071cdded add: pytils (работа с русским текстом) и etpgrf (типографика) 2026-06-15 18:23:18 +03:00
erjemin 57a6e36b33 mod: админка (13) для Лейблов LabelAdminForm и LabelAdmin 2026-06-14 11:23:03 +03:00
erjemin e08694ea82 mod: админка (12) для Музыкальных Стилей MusicStyleAdminForm и MusicStyleAdmin 2026-06-14 00:03:37 +03:00
erjemin 0cf6363671 mod: модели + миграции (preliminary 05)
Переделаны связи стилей: O2O Style→Article, M2M Item←Style (теперь связи артист-стиль — через Item)
2026-06-13 21:36:17 +03:00
erjemin 23cb31bc40 mod: админка (11) для источников ArtistAdminForm и ArtistAdmin 2026-06-13 13:43:00 +03:00
erjemin 60700076d1 mod: админка (10) minor 2026-06-13 12:26:43 +03:00
erjemin 8b45839c25 mod: админка (09) SellerAdminForm minor 2026-06-13 11:42:41 +03:00
erjemin f2f30bbd0a mod: админка (08) для источников SourceAdminForm и SourceAdmin 2026-06-12 23:27:19 +03:00
erjemin 6d3f5578f1 mod: в CodeMirror добавлено data-language для url 2026-06-12 23:24:23 +03:00
erjemin 8f5b2034c7 mod: в CodeMirror добавлено форматирование с отступом (при открытии на редактирование) и минификация (при сохранении) json 2026-06-12 21:09:42 +03:00
erjemin c47a4bc39e mod: в CodeMirror добавлена поддержка json 2026-06-12 20:12:55 +03:00
erjemin 7d534ba2a1 mod: админка (07) SellerAdminForm и SellerAdmin + патч CSS для высоты JSON-поля 2026-06-12 19:14:13 +03:00
erjemin 5d71ca291d mod: модели (preliminary 05) + миграции
А) Валюта перенесена из TbSource в TbSeller
2026-06-12 15:46:46 +03:00
erjemin de81cc1b34 mod: админка (06) Патч CSS для узких экранов. 2026-06-12 14:56:14 +03:00
erjemin bb915c82e1 mod: модели (preliminary 04) + миграции
А) Удалена таблица TbFormat (форматы носителей) и заменена на CharField в TbOffer.

Б) Удалено поле s_img_copyright из модел TbImage (для этого будет использовано поле author из filer).
2026-06-12 13:49:37 +03:00
erjemin 7f5ea8b567 mod: админка (06) ImageAdmin (07) minor3 2026-06-12 01:07:37 +03:00
erjemin 989722bf16 mod: админка (04) ImageAdmin (04) minor 2026-06-12 00:57:14 +03:00
erjemin fed2a1c5a0 mod: админка (03) ImageAdmin (03) fin (пропатчено, подключили стили) 2026-06-12 00:48:12 +03:00
erjemin c7049f7d8d add: CodeMirror 2026-06-11 14:52:12 +03:00
erjemin 1ea2292833 mod: админка (01) ImageAdmin (02) +виртуальные поле author из filer 2026-06-11 13:24:12 +03:00
erjemin 280383f02d mod: админка (01) ImageAdmin (01) +виртуальные поля из filer 2026-06-10 19:55:09 +03:00
erjemin 0ad98fec9d mod: django-filer настройка (14) fine 2026-06-10 17:19:55 +03:00
erjemin 71cac55221 mod: django-filer настройка (13) поддержка heif/heic (fine) 2026-06-09 20:30:33 +03:00
erjemin a5425b212d mod: django-filer настройка (12) поддержка heif/heic (работает с логами) 2026-06-09 18:54:54 +03:00
erjemin 6d6bb873e9 mod: django-filer настройка (11) поддержка heif/heic 2026-06-08 20:06:55 +03:00
erjemin 7fb5b0e44f mod: django-filer настройка (10) 2026-06-08 16:59:33 +03:00
erjemin dc94b8a4df mod: django-filer настройка (09) размещение файлов (fin) 2026-06-08 10:24:37 +03:00
erjemin fa559f2517 mod: django-filer настройка (08) размещение файлов 2026-06-08 01:37:26 +03:00
erjemin 7e63fae613 mod: django-filer настройка (07) размещение файлов 2026-06-08 00:47:57 +03:00
erjemin 3d301ad326 mod: django-filer настройка (06) преобразование в webp 2026-06-07 21:38:52 +03:00
erjemin 6a1f923fe4 mod: django-filer настройка (05) преобразование в webp 2026-06-07 21:24:00 +03:00
erjemin f99bbb5c6b mod: django-filer настройка (04) преобразование в webp 2026-06-07 20:57:46 +03:00
erjemin 62fe0722e5 mod: django-filer настройка (03) преобразование в webp 2026-06-07 20:36:54 +03:00
erjemin a6d9f963b3 mod: django-filer настройка (02) преобразование в webp 2026-06-07 14:08:06 +03:00
erjemin a17906c224 mod: django-filer настройка (01) 2026-06-07 00:33:12 +03:00
erjemin d3edbd8315 mod: поддержка media-файлов в dev 2026-06-06 22:00:38 +03:00
erjemin 2e5f8b923e add: админ-панель (draft 01) 2026-06-05 22:11:16 +03:00
erjemin 5088cffece add: миграция 01 (init) 2026-06-04 15:35:54 +03:00
erjemin 7da0e466b3 mod: модели (preliminary 03) 2026-06-04 15:34:53 +03:00
erjemin 094983c66a add: приложение frontend и настройки filer 2026-06-04 15:34:23 +03:00
erjemin f081361c28 mod: модели (preliminary 02) 2026-06-04 14:31:22 +03:00
erjemin b0846e13b9 mod: модели (preliminary) 2026-06-04 13:12:08 +03:00
erjemin e933b4a9b2 mod: модели (draft 14) 2026-06-04 11:06:29 +03:00
erjemin 48e38fb9cf mod: вакуум и время жизни коннекта с БД 2026-06-03 22:09:07 +03:00
erjemin fd70a44c81 mod: модели (draft 13) + музыкальные стили 2026-06-03 20:32:01 +03:00
erjemin b2154e815e mod: модели (draft 12) + счетчики просмотров и "в избранном" + хелперы для инкремента 2026-06-03 14:23:25 +03:00
erjemin 87ce677945 add: модели (draft 11) + индексы 2026-06-03 12:11:10 +03:00
erjemin edb7741ffa add: модели (draft 10) 2026-06-03 10:57:38 +03:00
erjemin 76e0350688 add: модели (draft 09) 2026-06-02 16:06:29 +03:00
erjemin 61be28786b add: модели (draft 08) 2026-06-01 20:27:45 +03:00
erjemin dbb6103378 add: модели (draft 07) 2026-05-31 14:18:07 +03:00
erjemin eec127912c add: модели (draft 06) + django-filer 2026-05-31 01:28:53 +03:00
erjemin 3181d71227 add: модели (draft 05) 2026-05-31 00:13:41 +03:00
erjemin 0741735fa5 add: модели (draft 04) 2026-05-30 19:58:30 +03:00
erjemin efd4695960 add: модели (draft 03) 2026-05-30 18:49:41 +03:00
erjemin 3f45a7e397 add: модели (draft 02) 2026-05-30 13:23:22 +03:00
erjemin be4839ecc2 add: модели (draft 01) 2026-05-28 11:44:20 +03:00
erjemin b2b38783cb minor: .env 2026-05-28 11:43:47 +03:00
erjemin f0502e605f mod: использование .env 2026-05-26 11:01:01 +03:00
erjemin 75d359aeb3 add: django-environ 2026-05-25 10:37:22 +03:00
erjemin 5b6c8a8ded add: Django 6.x инициализация 2026-05-24 21:08:38 +03:00
erjemin 5cb05a353f add: poetry 2026-05-24 21:07:43 +03:00
36 changed files with 8455 additions and 4 deletions
+26 -1
View File
@@ -1,3 +1,27 @@
# Локальная конфигурация для dev-окружения lpon-site.
DJANGO_DEBUG=True
DJANGO_SECRET_KEY=insecure-dev-key-change-me-in-prod
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver,lpon.ru
DJANGO_ADMINS=YouName:you@email.com
# Доверенные источники для CSRF (важно для Docker/Nginx)
# Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом gunicorn)
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://testserver,https://lpon.ru
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
DJANGO_ADMIN_URL=admin/
# База данных (SQLite)
DJANGO_SQLITE_NAME=lpon-db.sqlite3
# Почта SMTP
DJANGO_EMAIL_HOST=smtp.mail.ru
DJANGO_EMAIL_PORT=2525
DJANGO_EMAIL_HOST_USER=you@email.com
DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME
DJANGO_EMAIL_FROM=you@email.com
# ==============================================
# Системные пути на хосте (ТОЛЬКО ДЛЯ ПРОДАКШЕНА) # Системные пути на хосте (ТОЛЬКО ДЛЯ ПРОДАКШЕНА)
# Используется скриптом для генерации корректного Nginx конфига и alias к media-файлам. # Используется скриптом для генерации корректного Nginx конфига и alias к media-файлам.
# На локальной машине разработчика (Dev) эта переменная игнорируется. # На локальной машине разработчика (Dev) эта переменная игнорируется.
@@ -5,6 +29,7 @@
HOST_PROJECT_PATH=/home/user/docker-app/your-site HOST_PROJECT_PATH=/home/user/docker-app/your-site
# Настройки достпа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать # ==============================================
# Настройки достпа к пакетам в репозитории, чтобы watchtower мог проверять их свежесть и скачивать
REPO_USER=xxxxx REPO_USER=xxxxx
REPO_PASS=xxxxx REPO_PASS=xxxxx
+3 -2
View File
@@ -363,7 +363,8 @@ db.sqlite3-wal
public/media/* public/media/*
!public/media/README.md !public/media/README.md
staticfiles/ staticfiles/
public/static/static_collected/ public/static_collected/
.github/
# Data Backup # Data Backup
database/data.json database/
+10
View File
@@ -1,6 +1,16 @@
# Сплеш-экран сайта LPON.RU # Сплеш-экран сайта LPON.RU
![Version](https://img.shields.io/badge/version-0.1.0-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Python](https://img.shields.io/badge/python-3.12-yellow)
![Django](https://img.shields.io/badge/django-6.0-green)
[![Static Badge](https://img.shields.io/badge/ОРИГИНАЛ-git.cube2.ru-green)](https://git.cube2.ru/erjemin/2018-lpon-site)
[![Static Badge](https://img.shields.io/badge/ТИПОГРАФИКА-etpgrf-green)](https://typograph.cube2.ru/)
## Архив ## Архив
Легаси проекта, который я задумал в 2018 году, но так и не собрался его реализовать. Легаси проекта, который я задумал в 2018 году, но так и не собрался его реализовать.
_Не пора ли продолжить?_ :) _Не пора ли продолжить?_ :)
Ведется разработка на Python 3.12 и Django 6.0.
@@ -0,0 +1,496 @@
# План миграции: TbImage → ImageMetadata (Filer Extension)
**Дата:** 2026-07-07
**Время:** 21:09
**Автор:** Copilot CLI
**Статус:** В планировании
---
## Обзор
Переконфигурирование архитектуры работы с изображениями:
- **Текущее состояние:** Модели ссылаются на TbImage, которая ссылается на FilerImageField
- **Целевое состояние:** Модели ссылаются прямо на filer.Image, дополнительные метаданные хранятся в переименованной TbImage (→ ImageMetadata)
---
## Фаза 1: Подготовка и анализ
### Задача 1.1: Аудит использования TbImage
- [ ] Найти все FK на TbImage в моделях
- [ ] Найти все M2M связи на TbImage (см. `TbOffer.k_offer_to_image`)
- [ ] Найти все места в админке, которые работают с TbImage
- [ ] Найти все места в парсерах/сигналах, которые создают/обновляют TbImage
- [ ] Документировать каждое найденное место
### Задача 1.2: Анализ filer.Image структуры
- [ ] Проверить какие даты есть в filer.Image модели (`modified_at`, `uploaded_at` или их эквиваленты)
- [ ] Проверить есть ли `related_name` у FilerImageField в TbImage или он будет auto-generated
- [ ] Убедиться что filer работает с сигналами при изменении файла
**Результат:** Составить полный список всех мест, требующих изменения
---
## Фаза 2: Модификация моделей
### Задача 2.1: Переименование и упрощение TbImageMetadata
Архитектура: **МИНИМАЛИЗМ + JSON для гибкости**
- [ ] Переименовать класс TbImage → TbImageMetadata
- [ ] Добавить/проверить `related_name='metadata'` на FilerImageField
```python
image = FilerImageField(
...,
related_name='metadata' # ← для доступа: filer_image.metadata
)
```
- [ ] **ОСТАВИТЬ в TbImageMetadata:**
- `image` (OneToOneField → filer.Image)
- `i_img_sort` (IntegerField, db_index=True) ← ТОЛЬКО это индексируется!
- `j_img_metadata` (JSONField, null=True, blank=True) ← всё остальное сюда
- [ ] **УДАЛИТЬ ВСЕ остальные поля:**
- ❌ `l_img_source` (будет в JSON если нужно)
- ❌ `l_img_reality` (дефолт по контексту: TbItem→abstract, TbOffer админка→real, иное→abstract)
- ❌ `s_img_src_url` (в JSON если нужно)
- ❌ `f_img_confidence_score` (в JSON если нужно, парсеры его заполнят)
- ❌ `t_img_created` (используем filer.File.uploaded_at)
- ❌ `t_img_updated` (используем filer.File.modified_at)
- [ ] Обновить `Meta.verbose_name` на "Метаданные изображения"
- [ ] Обновить `Meta.ordering` — просто `('i_img_sort',)`
**ПОЧЕМУ?**
- Парсеров еще нет, не знаем какие поля реально нужны
- Справочные поля (источник, тип, достоверность) не нужны для индексации/фильтрации
- JSON дает гибкость: позже когда парсеры появятся, просто добавим новые ключи или выносим в таблицу если нужен индекс
- YAGNI: не создаем сложность пока она не нужна
### Задача 2.2: Обновление FK на изображения во всех моделях
- [ ] TbArticle: `k_article_to_image` — ForeignKey(TbImage) → ForeignKey(filer.Image)
- [ ] **TbItem: добавить `k_item_to_image` — ForeignKey(filer.Image, blank=True, null=True)**
- Обложка альбома/релиза (всегда `l_img_reality = 'abstract'`)
- Дефолтное значение при создании ImageMetadata: `l_img_reality = 'abstract'`
- [ ] TbOffer: M2M `k_offer_to_image` — на filer.Image вместо TbImage
- Может переиспользовать картинку из TbItem или иметь свою
- В админке: `l_img_reality` может быть `'real'` (реальная фото товара) или `'abstract'` (из каталога)
- При создании через админку: дефолт `l_img_reality = 'real'` (человек загружает реальные фото)
- При создании через парсер: `l_img_reality = 'abstract'` (парсер берет с Discogs и т.д.)
- [ ] [Проверить другие модели] — все остальные модели с FK на картинки
- [ ] Для каждого изменения:
- Обновить саму модель
- Создать Django миграцию
- Обновить админку (инлайны)
### Задача 2.3: Создание миграции данных
- [ ] Миграция для переименования таблицы (TbImage → ImageMetadata или оставить таблицу, но переименовать модель?)
- [ ] Миграция для удаления полей `t_img_created`, `t_img_updated` из ImageMetadata
- [ ] Обработка сирот (ImageMetadata без соответствующего filer.Image) — удалить или связать
**Важно:** Порядок миграций критичен! Сначала добавляем новые FK/M2M, потом удаляем старые.
---
## Фаза 3: Сигналы и автоматизация
### Задача 3.1: Signal на создание filer.Image → auto-create TbImageMetadata
```python
# В apps.py
@receiver(post_save, sender=filer.models.Image)
def auto_create_image_metadata(sender, instance, created, **kwargs):
"""При создании filer.Image автоматически создавать TbImageMetadata"""
if created:
TbImageMetadata.objects.get_or_create(
image=instance,
defaults={
'i_img_sort': 0,
# j_img_metadata оставляем пустым, заполняется по мере необходимости
}
)
```
### Задача 3.2: Обратный сигнал — синхронизация дат (опционально)
```python
# В apps.py — ЕСЛИ нужно обновлять modified_at в filer при изменении i_img_sort
@receiver(post_save, sender=TbImageMetadata)
def sync_image_modification_date(sender, instance, **kwargs):
"""При обновлении i_img_sort обновляем modified_at в filer.File"""
# Обновляем filer.File.modified_at через SQL update (без повторного save()!)
from filer.models import File
File.objects.filter(id=instance.image.id).update(modified_at=now())
```
**ПРИМЕЧАНИЕ:** Сигнал опционален. Если не нужно обновлять дату каждый раз при изменении сортировки — можно пропустить.
### Задача 3.3: Сигнал на удаление
- [ ] При удалении ImageMetadata → не удаляем filer.Image (метаданные удаляются, файл остаётся)
- [ ] При удалении filer.Image → каскадно удаляем ImageMetadata (on_delete=models.CASCADE)
---
## Фаза 4: Админка и UI
### Задача 4.1: Создание ImageMetadataAdminMixin
```python
class ImageMetadataAdminMixin:
"""Миксин для админок, содержащих связь с filer.Image"""
# Автоматически добавлять инлайн ImageMetadata при наличии FK на Image
```
### Задача 4.2: Создание TbImageMetadataInline
- [ ] StackedInline для TbImageMetadata
- [ ] Поля: `i_img_sort`, `j_img_metadata`
- [ ] `j_img_metadata` как JSON editor (админка Django поддерживает JSONField)
- [ ] Всё просто и минималистично
### Задача 4.3: Обновление админок моделей
- [ ] TbArticleAdmin — добавить TbImageMetadataAdminMixin
- [ ] **TbItemAdmin — добавить мixin для обложки**
- Поле `k_item_to_image` с инлайном TbImageMetadata
- `i_img_sort` редактируемо
- `j_img_metadata` может содержать замечания ("абстрактная", "с Discogs" и т.д.) если нужно
- [ ] **TbOfferAdmin — добавить мixin для картинок товара**
- M2M к filer.Image с инлайном TbImageMetadata
- `i_img_sort` редактируемо (для сортировки фото товара)
- `j_img_metadata` редактируемо (админ может добавить замечания)
- [ ] [Все остальные администраторы с изображениями]
---
## Фаза 5: Парсеры и интеграция
### Задача 5.1: Подготовка к парсерам (FUTURE-PROOF)
- [ ] Найти все парсеры (их еще нет, но логика готова)
- [ ] Парсеры смогут заполнять `j_img_metadata` с любыми данными:
```json
{
"source": "discogs",
"reality": "abstract",
"confidence": 0.95,
"original_url": "https://...",
"notes": "..."
}
```
- [ ] `i_img_sort` может устанавливаться парсером если нужна специальная сортировка
- [ ] **Дефолтная логика (без парсера):**
- TbItem → картинка? → j_img_metadata = `{"reality": "abstract"}`
- TbOffer + админка → картинка? → j_img_metadata пустой или `{}`
- На фронте: если не указано → apply defaults
### Задача 5.2: Миграция существующих данных (ТЫ ДЕЛАЕШЬ)
- [ ] Перенести все TbImage в TbImageMetadata (переименование таблицы)
- [ ] Заполнить `i_img_sort` из старого поля (если было)
- [ ] `j_img_metadata` заполнить на основе старых полей если они важны
```json
{
"old_l_img_source": "...",
"old_l_img_reality": "...",
"old_confidence": "..."
}
```
- [ ] Или просто пусто, парсеры потом заполнят правильно
---
## Фаза 6: Тестирование и валидация
### Задача 6.1: Unit тесты
- [ ] Тест создания filer.Image → автоматическое создание ImageMetadata
- [ ] Тест обновления ImageMetadata → синхронизация дат в filer.Image
- [ ] Тест удаления ImageMetadata → filer.Image остаётся
- [ ] Тест удаления filer.Image → каскадное удаление ImageMetadata
### Задача 6.2: Интеграционные тесты
- [ ] Admin interface — создание/редактирование с инлайном
- [ ] Парсеры — создание корректных записей
- [ ] Шаблоны — корректный доступ к метаданным через `image.metadata.i_img_sort`
### Задача 6.3: Миграция данных (если база содержит данные)
- [ ] Запустить миграции
- [ ] Проверить целостность данных
- [ ] Проверить что все ImageMetadata связаны с filer.Image
- [ ] Проверить обратные связи работают
---
## Фаза 7: Финализация
### Задача 7.1: Удаление старого кода
- [ ] Удалить все ссылки на старый TbImage (если полностью переименовали на ImageMetadata)
- [ ] Очистить импорты
- [ ] Обновить комментарии и документацию
### Задача 7.2: Документирование
- [ ] Обновить ER-диаграмму в моделях
- [ ] Описать новую архитектуру в README или docs
- [ ] Примеры использования для разработчиков
### Задача 7.3: Развёртывание
- [ ] Code review
- [ ] Тестирование на staging
- [ ] Развёртывание на production с миграциями
- [ ] Мониторинг логов на ошибки
---
## Потенциальные ловушки
⚠️ **Определение источника создания ImageMetadata (админка vs парсер)**
Решение: добавить context или flag в сигнал, или использовать user_id в сигнале (если user_id = None → парсер, иначе админка)
⚠️ **TbItem.l_img_reality всегда должна быть 'abstract'**
Решение: signal принудительно устанавливает и делает read-only в админке
⚠️ **TbOffer может иметь как свои картинки, так и наследовать из TbItem**
Решение: M2M на filer.Image, админ может выбирать любые картинки и устанавливать l_img_reality на месте
---
## Оценка сложности
| Фаза | Сложность | Время (ч) | Приоритет |
|------|-----------|-----------|-----------|
| 1 | Низкая | 0.5 | Высокий |
| 2 | Средняя | 2-3 | Высокий |
| 3 | Средняя | 1-2 | Высокий |
| 4 | Низкая | 1 | Высокий |
| 5 | Средняя | 1-2 | Высокий |
| 6 | Средняя | 1-2 | Средний |
| 7 | Низкая | 1 | Средний |
**Итого:** ~8-13 часов
---
## Уточнения из ответов
✅ **1. Модель называется:** `TbImageMetadata` (переименуем TbImage)
✅ **2. Про performa & filer:**
- filer имеет две таблицы: File + Image (Image extends File через OneToOne)
- modified_at находится в таблице File (базовая для всех файлов)
- TbImageMetadata расширяет Image через OneToOne = это ОК, перформанс не критичен
- **Риск цикличности НИЗКИЙ:** обновляем ТОЛЬКО дату, не весь объект
✅ **3. M2M на TbOffer:**
- `k_offer_to_image` это M2M (Django автоматически создает промежуточную таблицу)
- ОК, оставляем M2M
✅ **4. Парсеры:**
- Создаются ПОЗЖЕ (в отдельном спринте)
- Сейчас делаем инфраструктуру для них
- Парсеры будут работать с filer напрямую, заполнять ImageMetadata вручную (или через свои сигналы позже)
✅ **5. Цикличность:**
- Филер обновляет modified_at при изменении файла — ОК, автоматическое
- Мы обновляем modified_at при изменении метаданных — **ТОЛЬКО дату**, не вызовет cascade
- **Цикличности НЕ будет** если правильно реализовать сигнал (через `update()`, не `save()`)
✅ **6. Определение источника сигнала (админка vs парсер):**
- **Решение:** Передавать через `using` параметр или context manager в signal'е
- **Для парсеров:** Парсеры могут создавать ImageMetadata вручную (без сигналов) или отключать сигналы
- **Для админки:** Сигналы работают стандартно
✅ **7. Миграцию делаешь сам** — ясно!
---
## ФИНАЛЬНЫЙ ПЛАН: ПОРЯДОК РЕАЛИЗАЦИИ
### ЭТАП 1: ПОДГОТОВКА И МОДЕЛИ (БЕЗ МИГРАЦИЙ)
**Шаг 1.1:** Обновить модель TbImage → TbImageMetadata
- Переименовать класс с `TbImage` на `TbImageMetadata`
- Добавить `related_name='metadata'` на FilerImageField ← **ВСТРЕЧНЫЙ ИНДЕКС! Упростит миграции**
- **ПОКА ОСТАВИТЬ** t_img_created, t_img_updated (удалим после миграции данных)
- **НЕ создавать** миграцию на этом этапе
**Шаг 1.2:** Обновить все FK на картинки в моделях
- TbArticle: `k_article_to_image` ForeignKey(TbImage) → ForeignKey(filer.Image)
- **TbItem: добавить новое поле** `k_item_to_image = ForeignKey(filer.Image, blank=True, null=True)`
- TbOffer: `k_offer_to_image` M2M(TbImage) → M2M(filer.Image)
- Все остальные модели с картинками
- **НЕ создавать** миграции на этом этапе! Только обновляем код
**Шаг 1.3:** Обновить админку (без инлайнов пока)
- Обновить все FieldChoices в админке на новые FK
- **НЕ добавлять** ImageMetadataInline пока!
- **НЕ добавлять** миксин пока!
- Просто чтобы админка не ломалась с новыми FK
**РЕЗУЛЬТАТ:** Код скомпилирован, но БД не трогали. Всё готово к миграциям.
---
### ЭТАП 2: МИГРАЦИИ (ТЫ ДЕЛАЕШЬ САМИ)
**Шаг 2.1:** Создать миграции Django
- Миграция на переименование класса TbImage → TbImageMetadata (если нужно переименовать таблицу, то custom миграция)
- Миграция на удаление старых FK из TbArticle, TbOffer и т.д.
- Миграция на добавление новых FK/M2M на filer.Image
- Миграция на добавление новых полей (TbItem.k_item_to_image и т.д.)
**Шаг 2.2:** Миграция данных
- Перенести существующие картинки из TbImage в filer.Image (если нужно)
- Привязать существующие записи к новым FK
- **Или:** Просто очистить TbImage и начать с нуля (если картинок мало)
**Шаг 2.3:** Удалить t_img_created и t_img_updated ПОСЛЕ успешной миграции
- Отдельная миграция на удаление этих двух полей
- Применяется ПОСЛЕ того как все данные перенесены и связи обновлены
- **ПОРЯДОК ВАЖЕН:** сначала новые FK/M2M, потом удаляем старые поля
**РЕЗУЛЬТАТ:** БД в новом состоянии, приложение рабочее (но без автоматизации через сигналы).
---
### ЭТАП 3: СИГНАЛЫ И АДМИНКА (АВТОМАТИЗАЦИЯ)
**Шаг 3.1:** Создать Signal для auto-create TbImageMetadata
```
Когда: post_save filer.Image, created=True
Делает: автоматически создавать TbImageMetadata с дефолтами
Дефолты: i_img_sort=0, l_img_source='manual', l_img_reality зависит от контекста
```
**Шаг 3.2:** Создать Signal для синхронизации дат
```
Когда: post_save TbImageMetadata
Делает: обновляет filer.File.modified_at через SQL update (БЕЗ save()!)
Важно: modified_at из File, а не Image!
```
**Шаг 3.3:** Создать миксин для админки
```
ImageMetadataAdminMixin: автоматически добавляет инлайн для TbImageMetadata
Применяется к: TbArticleAdmin, TbOfferAdmin, и всем остальным
```
**Шаг 3.4:** Обновить админку с инлайнами
- TbArticleAdmin добавить миксин
- TbItemAdmin добавить миксин + читать-только l_img_reality для обложек
- TbOfferAdmin добавить миксин + M2M выбор картинок + l_img_reality редактируемо
- Остальные модели с картинками
**РЕЗУЛЬТАТ:** Всё работает автоматически. Юзер создает/редактирует картинки — метаданные появляются и синхронизируются.
---
### ЭТАП 4: ПОДГОТОВКА К ПАРСЕРАМ (FUTURE-PROOF)
**Шаг 4.1:** Документировать как парсерам использовать новую архитектуру
- Парсеры создают filer.Image напрямую
- TbImageMetadata автоматически создается через сигнал
- Парсеры могут переопределять значения через direct update
**Шаг 4.2:** Создать helper для парсеров (optional)
```
Функция: create_image_from_url(url, l_img_source, l_img_reality, ...)
Делает: загружает в filer.Image + заполняет TbImageMetadata правильно
Это обычно нужно потом, но архитектура к этому готова
```
**Шаг 4.3:** Отключение сигналов для парсеров (если нужно)
```
Если парсер хочет создавать без сигналов:
- Парсер может установить какой-то флаг (например, signals.disconnect())
- Или парсер создает все сам без сигналов
- ИЛИ передавать context через signal'ы (позже если понадобится)
```
**РЕЗУЛЬТАТ:** Архитектура готова для будущих парсеров. Они смогут работать либо через сигналы, либо вручную.
---
## УТОЧНЕНИЕ: КАРТИНКИ ТОЛЬКО В TBIMAGE
**Важный факт:** Все картинки сейчас в filer привязаны ТОЛЬКО к TbImage, больше никуда не привязаны.
**Это упрощает миграции!**
- Нет данных в других моделях которые нужно конвертировать
- `related_name='metadata'` на FilerImageField даст встречный доступ: `filer_image.metadata`
- Миграция будет проще: просто переименовать таблицу, добавить новые FK структуры, готово
**Шаги миграции будут:**
1. Переименовать TbImage → TbImageMetadata (в таблице)
2. Добавить новые поля для TbItem.k_item_to_image
3. Очистить/переинициализировать существующие данные если нужно
4. ПОСЛЕ всего этого — удалить t_img_created, t_img_updated
---
## ОТВЕТ НА ВОПРОС: КОГДА УДАЛЯТЬ t_img_created И t_img_updated?
**Правильный порядок:**
1. **Этап 1 (код):** Оставить поля в коде модели (пока!)
2. **Этап 2 (миграции) — ПЕРВЫЕ миграции:**
- Переименование TbImage → TbImageMetadata
- Добавление новых FK структур
- Миграция данных
3. **Этап 2 (миграции) — ПОСЛЕДНЯЯ миграция:**
- Удалить t_img_created и t_img_updated из БД
4. **ПОТОМ, Этап 1 (код):** Удалить эти поля из кода модели
**ПОЧЕМУ такой порядок?**
- Сначала применяем миграции НОВЫХ структур — убеждаемся что всё работает
- ПОТОМ удаляем старые поля — когда новая структура стабильна
- Если что-то пошло не так — есть откат через старые поля
**ПРАКТИЧЕСКИ:**
- Этап 1: TbImageMetadata всё ещё имеет `t_img_created`, `t_img_updated` в коде
- Ты создаёшь первую миграцию (переименование, новые FK)
- Ты создаёшь вторую миграцию (удаление дат из БД)
- Применяешь обе
- ПОТОМ я удаляю эти поля из самого кода модели (простое удаление из файла)
1. **ЭТАП 1 (2-3 часа)** — Обновить код моделей и админки, БД не трогаем
2. **ЭТАП 2 (30 мин - 2 часа)** — Написать миграции и применить их (ты делаешь!)
3. **ЭТАП 3 (2-3 часа)** — Написать сигналы, админка миксины, протестировать
4. **ЭТАП 4 (30 мин)** — Документировать для будущих парсеров
**ОБЩЕЕ ВРЕМЯ:** ~7-9 часов
---
## НОВАЯ АРХИТЕКТУРА: МИНИМАЛИЗМ + JSON
**TbImageMetadata содержит:**
- `image` (OneToOne → filer.Image)
- `i_img_sort` (IntegerField, db_index=True) ← **ТОЛЬКО поле с индексом!**
- `j_img_metadata` (JSONField, null=True) ← **Всё остальное сюда**
**Почему?**
- ✅ Парсеров еще нет → не знаем какие поля реально нужны
- ✅ Справочные данные не используются для фильтрации/индексации
- ✅ JSON гибко хранит всё: источник, тип, достоверность, URL и т.д.
- ✅ Если потом парсеры потребуют индексы → просто выносим поле из JSON
- ✅ Дефолтная логика: TbItem → abstract, TbOffer админка → любое значение
**Пример j_img_metadata:**
```json
{
"source": "parser",
"reality": "abstract",
"confidence": 0.95,
"original_url": "https://discogs.com/...",
"notes": "Обложка из каталога"
}
```
---
## ИТОГОВЫЙ ПОРЯДОК РЕАЛИЗАЦИИ (ОБНОВЛЕНО)
🔑 **Этап 1 БЕЗ МИГРАЦИЙ** — сначала весь код готовый, потом БД меняем
🔑 **Этап 2 ТЫ ДЕЛАЕШЬ** — я не трогаю, ты контролируешь миграции
🔑 **Архитектура МИНИМАЛИСТИЧНА** — только i_img_sort индексируется, всё остальное в JSON
🔑 **Гибкость МАКСИМАЛЬНАЯ** — JSON позволяет добавлять любые данные без миграций БД
🔑 **Парсеры готовы** — при создании просто заполняют j_img_metadata как нужно
🔑 **Дефолтная логика** — на фронте если метаданных нет → применяем defaults по контексту
---
**Готов начинать с ЭТАПА 1?** Или еще вопросы/уточнения?
+9
View File
@@ -0,0 +1,9 @@
# Каталог для NPM-сборки Codemirror6 (для фронтенда админки).
# Папка `node_modules/` будет создана в процессе NPM-сборки Codemirror 6 (для фронтенда админки).
# Её соджержимое не должно попадать в репозиторий. Готовая сборка будет
# перемещна в ../public/static/codemirror/editor.js
node_modules/
src/
@@ -0,0 +1,152 @@
# CodeMirror 6 для Django Admin на LPON.RU
## Обзор
Это руководство описывает, как использовать и обновлять сборку CodeMirror 6, которая используется для редактирования текстовых полей в админке Django.
## Структура проекта
- `frontend-assembly/`: Исходные файлы и скрипты для сборки.
- `package.json`: Список зависимостей CodeMirror 6.
- `package-lock.json`: Файл с зафиксированными версиями зависимостей. **Не редактировать вручную!**
- `build-codemirror6.sh`: Скрипт, который генерирует и собирает финальный JS-бандл.
- `public/static/codemirror/`: Каталог с готовыми файлами для Django.
- `editor.js`: Финальный, минифицированный JS-бандл. **Не редактировать вручную!**
- `codemirror-styles.css`: Кастомные стили для интеграции с админкой.
- `codemirror-patch.js`: JS-патч для управления размерами редактора.
## Как собрать бандл
После клонирования репозитория или обновления зависимостей, выполните:
```bash
# Перейдите в каталог сборки
cd frontend-assembly
# Установите зависимости и соберите бандл
bash ./build-codemirror6.sh
```
## Как использовать в Django Admin
### 1. Подключение в ModelAdmin
В вашем `admin.py` используйте `Media` класс для подключения необходимых файлов.
```python
# frontend/admin.py
class YourModelAdmin(admin.ModelAdmin):
class Media:
css = {
'all': ('codemirror/codemirror-styles.css',)
}
js = (
'codemirror/editor.js',
'codemirror/codemirror-patch.js',
)
```
### 2. Активация для полей
Чтобы превратить стандартный `Textarea` в редактор CodeMirror, используйте кастомную форму или метод `formfield_for_dbfield` и добавьте к виджету специальные `data-` атрибуты.
```python
# frontend/admin.py
class YourModelAdmin(admin.ModelAdmin):
def formfield_for_dbfield(self, db_field, request, **kwargs):
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
# Активируем CodeMirror для всех текстовых полей
if isinstance(formfield.widget, forms.Textarea):
formfield.widget.attrs['data-codemirror-editor'] = 'true'
# Устанавливаем язык в зависимости от имени поля
if 'j_' in db_field.name:
# Обычно в моделях LPON поля с префиксом 'j_' содержат JSON
formfield.widget.attrs['data-language'] = 'json'
elif 'html' in db_field.name:
formfield.widget.attrs['data-language'] = 'html'
else:
formfield.widget.attrs['data-language'] = 'text' # Обычный текст
# Добавляем утилитарные CSS-классы для стилизации
if db_field.name == 'i_img_sort':
formfield.widget.attrs['class'] = 'codemirror-width-s codemirror-no-lines'
elif db_field.name == 'j_seller_metadata':
formfield.widget.attrs['class'] = 'codemirror-width-xl codemirror-min-height-m'
return formfield
```
### 3. Поддерживаемые языки
- `javascript`
- `css`
- `json`
- `html` (по умолчанию, в него встроенны режимы подсветки синтенза HTML, XML, CSS и JavaScript)
- `text` (обычный текст, без подсветки синтакса, unocode-режим)
## Кастомизация
### Управление размерами и стилями
В файле `public/static/codemirror/codemirror-styles.css` определены утилитарные CSS-классы для управления внешним
видом редакторов:
- **Ширина**: `.codemirror-width-s`, `.codemirror-width-m`, `.codemirror-width-l`, `.codemirror-width-xl`
- **Минимальная высота**: `.codemirror-min-height-5` и `.codemirror-min-height-10` (по 5 и 10 строк соответственно)
- **Отключение номеров строк**: `.codemirror-no-lines`
Просто примените нужный класс к атрибуту `class` виджета в `admin.py`. По аналогии можно создавать свои классы
для своих настройек.
## Как добавить новый язык (например, jinja2 или xml)
1. **Добавить зависимость**: Откройте `frontend-assembly/package.json` и добавьте в `devDependencies` новый языковой пакет. Версию выбирайте близкую к другим пакетам `@codemirror/lang-*`.
```json
// package.json
...
"@codemirror/lang-json": "6.0.1",
"@codemirror/lang-xml": "6.1.0", // <-- Новая строка для добавления xml
"@codemirror/language": "6.12.3",
...
```
2. **Обновить скрипт сборки**: Откройте `frontend-assembly/build-codemirror6.sh` и внесите два изменения в секцию `cat > "$WORK_DIR/src/editor.js" <<'EOF'`:
- Добавьте импорт:
```javascript
import { xml } from '@codemirror/lang-xml';
```
- Добавьте условие для его использования:
```javascript
} else if (language === 'xml') {
extensions.unshift(xml());
}
```
3. **Пересобрать бандл**:
```bash
cd frontend-assembly
npm install # Установит новый пакет и обновит package-lock.json
bash build-codemirror6.sh # Пересоберет editor.js с поддержкой XML
```
4. **Использовать в админке**: Теперь вы можете использовать `data-language="xml"` для любого поля.
## Обновление зависимостей
1. **Проверить устаревшие пакеты**:
```bash
cd frontend-assembly
npm outdated
```
2. **Обновить версии**: Аккуратно обновите номера версий в `package.json`.
3. **Установить обновления и пересобрать**:
```bash
npm install
bash build-codemirror6.sh
```
4. **Закоммитить изменения**: Добавьте в коммит обновленные `package.json`, `package-lock.json` и `public/static/codemirror/editor.js`.
+91
View File
@@ -0,0 +1,91 @@
# frontend-assembly
Эта папка отвечает за сборку CodeMirror 6 для админки Django.
Идея здесь простая: код редактора собирается как готовый минифицированный бандл,
а в репозиторий и в `public/static` попадает только готовый JS CodeMirror.
## Что здесь лежит
### `package.json`
Главный файл npm-проекта.
В нём описано:
- имя сборочного пакета;
- версия;
- зависимость от `esbuild` и пакетов CodeMirror 6;
- npm-скрипт `build`.
Скрипт `build` собирает `src/editor.js` в файл:
```bash
../public/static/codemirror/editor.js
```
При сборке используется `--minify`, потому что редактор нужен как готовый
чёрный ящик для админки, а не как объект для отладки.
### `package-lock.json`
Файл фиксации версий npm-зависимостей.
Он нужен, чтобы сборка была воспроизводимой:
- `npm ci` ставит ровно те версии, которые уже зафиксированы;
- одинаковый результат получается и на любом компьютере, и при любом новом клоне репозитория;
- при повторной сборке не подтягиваются случайные новые версии пакетов.
### `build-codemirror6.sh`
Основной скрипт сборки.
Он делает всё сам:
1. проверяет наличие `npm`;
2. создаёт временную рабочую папку;
3. временно создаёт там `src/editor.js`;
4. устанавливает зависимости через `npm ci`;
5. запускает `npm run build`;
6. кладёт готовый бандл в `public/static/codemirror/editor.js`;
7. удаляет временные `src/` и `node_modules/` после завершения.
Это означает, что в рабочем дереве не остаётся мусора от сборки.
### `src/editor.js`
Временный исходник редактора.
Он **не хранится в репозитории** как постоянный файл: скрипт создаёт его во
временной директории только на время сборки.
Внутри этого файла живёт минимальная инициализация CodeMirror 6:
- монтирование на `textarea[data-codemirror-editor]`;
- HTML-режим;
- JavaScript-режим;
- CSS-режим;
- тема `oneDark`;
- перенос строк;
- синхронизация значения редактора обратно в скрытую textarea.
Если позже захочется менять поведение редактора, логика правится либо в шаблоне,
который выдаёт этот исходник, либо прямо в сборочном скрипте.
### `public/static/codemirror/editor.js`
Готовый результат сборки -- `public/static/codemirror/editor.js`. Именно этот файл подключается в Django-админку.
Он уже минифицирован и готов к использованию как обычная статика.
## Как запускать сборку
Из корня проекта:
```bash
bash ./frontend-assembly/build-codemirror6.sh
```
После успешного запуска в проекте должен обновиться только один полезный артефакт:
```bash
public/static/codemirror/editor.js
```
## Коротко о логике работы
Смысл этой папки такой:
- собрать CodeMirror 6 один раз;
- не держать в репозитории и контейнере лишний фронтенд-мусор;
- оставить Django только готовый JS-бандл CodeMirror для админки.
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Скрипт создаёт временную рабочую папку, ставит зависимости через `npm ci`, собирает
# минимизированный бандл и затем сам удаляет временные `src/` и `node_modules/`.
# В проекте остаётся только готовая статика:
# * `public/static/codemirror/editor.js`
#
# Запуск:
# bash ./frontend-assembly/build-codemirror6.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_DIR="$ROOT_DIR/../public/static/codemirror"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codemirror6.XXXXXX")"
log() {
printf '[codemirror6] %s\n' "$*"
}
fail() {
printf '[codemirror6] %s\n' "$*" >&2
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
fail 'Не найден `npm`. Установи Node.js и повтори сборку.'
fi
if [[ ! -f "$ROOT_DIR/package.json" ]]; then
fail "Не найден package.json: $ROOT_DIR/package.json"
fi
if [[ ! -f "$ROOT_DIR/package-lock.json" ]]; then
fail "Не найден package-lock.json: $ROOT_DIR/package-lock.json"
fi
mkdir -p "$WORK_DIR/src" "$OUTPUT_DIR"
cp "$ROOT_DIR/package.json" "$ROOT_DIR/package-lock.json" "$WORK_DIR/"
cat > "$WORK_DIR/src/editor.js" <<'EOF'
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, lineNumbers, placeholder } from '@codemirror/view';
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { solarizedDark, solarizedLight } from '@uiw/codemirror-theme-solarized';
const themeCompartment = new Compartment();
const processedForms = new Set();
function isDarkTheme() {
const rootTheme = document.documentElement.dataset.theme;
if (rootTheme === 'dark') return true;
if (rootTheme === 'light') return false;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function reconfigureTheme(view) {
view.dispatch({
effects: themeCompartment.reconfigure(isDarkTheme() ? solarizedDark : solarizedLight),
});
}
function initCodeMirrorEditors() {
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
const language = textarea.dataset.language || 'text';
const inputMode = textarea.dataset.inputMode || 'default';
let initialDoc = textarea.value ?? '';
const wrapper = document.createElement('div');
wrapper.className = 'cm6-editor-wrapper';
textarea.insertAdjacentElement('beforebegin', wrapper);
if (language === 'json') {
try {
const parsed = JSON.parse(initialDoc);
initialDoc = JSON.stringify(parsed, null, 2);
} catch (e) {
console.warn("CodeMirror: Initial content is not valid JSON, displaying as is.", e);
}
}
const syncTextarea = EditorView.updateListener.of((update) => {
if (update.docChanged) {
textarea.value = update.state.doc.toString();
}
});
// --- Собираем расширения в правильном порядке ---
const extensions = [
syntaxHighlighting(defaultHighlightStyle),
syncTextarea,
themeCompartment.of(isDarkTheme() ? solarizedDark : solarizedLight),
];
// Язык
if (language === 'javascript') extensions.push(javascript());
else if (language === 'css') extensions.push(css());
else if (language === 'json') extensions.push(json());
else if (language === 'html') extensions.push(html());
// Режим ввода
if (inputMode === 'url') {
extensions.push(placeholder('https://example.com/path'));
// Для URL не добавляем lineWrapping и lineNumbers
} else {
extensions.push(EditorView.lineWrapping);
if (!textarea.classList.contains('codemirror-no-lines')) {
extensions.push(lineNumbers());
}
}
const state = EditorState.create({
doc: initialDoc,
extensions,
});
const view = new EditorView({
state,
parent: wrapper,
});
textarea.cmView = view;
reconfigureTheme(view);
const observer = new MutationObserver(() => reconfigureTheme(view));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
});
const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
colorScheme.addEventListener('change', () => reconfigureTheme(view));
const form = textarea.closest('form');
if (form && !processedForms.has(form)) {
form.addEventListener('submit', () => {
form.querySelectorAll('textarea[data-language="json"]').forEach(jsonTextarea => {
if (jsonTextarea.cmView) {
const prettyJson = jsonTextarea.cmView.state.doc.toString();
try {
const parsed = JSON.parse(prettyJson);
const minifiedJson = JSON.stringify(parsed);
jsonTextarea.value = minifiedJson;
} catch (e) {
console.warn("CodeMirror: Could not minify invalid JSON before submit.", e);
}
}
});
});
processedForms.add(form);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeMirrorEditors, { once: true });
} else {
initCodeMirrorEditors();
}
EOF
log "СОБИРАЮ CodeMirror 6 ДЛЯ ФРОНТЕНДА АДМИНКИ ПРОЕКТА"
log "Временная рабочая папка: $WORK_DIR"
cd "$WORK_DIR"
log 'Устанавливаю зависимости через npm ci'
npm ci
log 'Собираю CodeMirror 6'
export CM6_OUTPUT_DIR="$OUTPUT_DIR"
npm run build
log 'ГОТОВО'
+753
View File
@@ -0,0 +1,753 @@
{
"name": "lpon-codemirror6",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lpon-codemirror6",
"version": "0.1.0",
"devDependencies": {
"@babel/runtime": "7.29.7",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.1",
"@codemirror/language": "6.12.3",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@uiw/codemirror-theme-solarized": "4.25.10",
"esbuild": "0.28.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz",
"integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/@uiw/codemirror-theme-solarized": {
"version": "4.25.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-solarized/-/codemirror-theme-solarized-4.25.10.tgz",
"integrity": "sha512-mTP09g375qD86T09aRKQLHqkRSWDoot4dFUCtiyIsrvp1VeMBcA5ZSJHnI8A0lc8+hHRrvpL5fe3SnRFE+FoZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@uiw/codemirror-themes": "4.25.10"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/@uiw/codemirror-themes": {
"version": "4.25.10",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.10.tgz",
"integrity": "sha512-Fqiz1HIuDlDftcL+/O53V333UOH6MqQ84VbiQB5egn6u+uDwAqACp1FrdAoi4wgpR3b3TGW4Gr0wIYcrJSSz1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/language": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"dev": true,
"license": "MIT"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "lpon-codemirror6",
"version": "0.1.0",
"description": "CodeMirror build for LPON.RU Django Admin",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/editor.js --bundle --format=iife --minify --outfile=${CM6_OUTPUT_DIR:-../public/static/codemirror}/editor.js"
},
"devDependencies": {
"@babel/runtime": "7.29.7",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.1",
"@codemirror/language": "6.12.3",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@uiw/codemirror-theme-solarized": "4.25.10",
"esbuild": "0.28.0"
}
}
View File
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
import os
import hashlib
import logging
from io import BytesIO
from typing import Tuple
from django.apps import AppConfig
from django.core.files.base import ContentFile, File
from django.core.exceptions import ValidationError
from PIL import Image as PILImage
import pillow_heif
from lpon_site.settings import (
THUMBNAIL_WEBP_QUALITY,
FILER_ENABLE_PERMISSIONS,
FILER_UPLOADER_MAX_FILE_SIZE,
FILER_WHITELIST_FOR_PATH_ACCESS,
MIME_TYPE_WHITELIST,
FILE_VALIDATORS,
)
# Регистрируем плагин HEIF в Pillow для поддержки HEIC/HEIF файлов
pillow_heif.register_heif_opener()
# Настройка параллельного декодирования HEIF/HEIC файлов
# DECODE_THREADS: количество потоков для декодирования
# - 0 = использовать количество ядер процессора (рекомендуется)
# - N > 0 = использовать N потоков (больше потоков = быстрее, но больше памяти)
pillow_heif.options.DECODE_THREADS = 0
# Дополнительные опции Pillow для обработки изображений
# LOAD_TRUNCATED_IMAGES: загружать некорректно завершенные изображения
# (полезно для некачественных или поврежденных файлов)
PILImage.LOAD_TRUNCATED_IMAGES = True
logger = logging.getLogger(__name__)
# ==============================================================================
# Конфигурация приложения для фронтенда lpon
# ==============================================================================
class FrontendConfig(AppConfig):
name = 'frontend'
verbose_name = 'Сайт lpon.ru'
default_auto_field = 'django.db.models.AutoField'
def ready(self) -> None:
"""Инициализация Django Admin с кастомным заголовком и названиями."""
from django.contrib import admin
admin.site.site_header = 'Управление LPON'
admin.site.site_title = 'LPON Administrator'
admin.site.index_title = 'Добро пожаловать в LPON'
# ==============================================================================
# Кастомная конфигурация для django-filer
# Патчинг MultiStorageFieldFile.save() для автоматической конвертации в WebP
# ==============================================================================
class CustomFilerConfig(AppConfig):
name = 'filer'
verbose_name = 'Медиафайлы'
# Атрибуты конфигурации Django-Filer (импортированы из settings.py)
FILER_ENABLE_PERMISSIONS = FILER_ENABLE_PERMISSIONS
FILER_UPLOADER_MAX_FILE_SIZE = FILER_UPLOADER_MAX_FILE_SIZE
FILER_WHITELIST_FOR_PATH_ACCESS = FILER_WHITELIST_FOR_PATH_ACCESS
MIME_TYPE_WHITELIST = MIME_TYPE_WHITELIST
FILE_VALIDATORS = FILE_VALIDATORS
@staticmethod
def _convert_to_webp_if_needed(
name: str, content: File
) -> Tuple[File, str, bool, int | None, int | None]:
"""
Преобразует загруженное изображение в WebP формат.
Поддерживает JPEG, PNG, BMP, TIFF и HEIC/HEIF.
Args:
name: Имя файла с расширением
content: Содержимое файла (Django File object)
Returns:
Кортеж: (content, new_name, was_converted, image_width, image_height)
- content: WebP файл или исходный файл
- new_name: Новое имя файла (.webp)
- was_converted: Был ли произведен конвертирование
- image_width: Ширина изображения в пикселях (None если не конвертирован)
- image_height: Высота изображения в пикселях (None если не конвертирован)
Raises:
ValidationError: Если произошла ошибка при конвертации
Размеры ОБЯЗАТЕЛЬНО получаются из исходного изображения перед конвертацией.
При конвертации в WebP размеры пикселей не меняются, меняется только качество файла.
"""
_, original_ext = os.path.splitext(name)
# PNG исключено, т.к. их планирую использовать для иконок (может быть, верну позже)
convertible_extensions = [".jpg", ".jpeg", ".bmp", ".tiff", ".heic", ".heif"]
if original_ext.lower() in convertible_extensions:
try:
# Открываем исходный файл с помощью PIL
content.seek(0)
img = PILImage.open(BytesIO(content.read()))
# Получаем размеры изображения ДО конвертации (они не меняются при сохранении в WebP)
image_width, image_height = img.size
# Конвертируем CMYK в RGB если требуется
if img.mode == 'CMYK':
img = img.convert('RGB')
# Сохраняем в WebP формат в памяти
webp_buffer = BytesIO()
img.save(webp_buffer, format="WEBP", quality=THUMBNAIL_WEBP_QUALITY)
webp_data = webp_buffer.getvalue()
# Изменяем расширение на .webp
new_name = os.path.splitext(name)[0] + ".webp"
return ContentFile(webp_data), new_name, True, image_width, image_height
except Exception as e:
# Поднимаем ValidationError чтобы она показывалась в админке
error_msg = f"Ошибка при конвертации '{name}' в WebP: {str(e)}"
logger.error(error_msg, exc_info=True)
raise ValidationError(error_msg)
# Если файл не нуждается в конвертации, возвращаем как есть
content.seek(0)
return content, name, False, None, None
def ready(self) -> None:
"""
Инициализация: патчинг MultiStorageFieldFile.save() для WebP конвертации.
Добавление поддержки HEIC/HEIF в filer.
"""
from filer.fields.multistorage_file import MultiStorageFieldFile
from filer import settings as filer_settings
original_save = MultiStorageFieldFile.save
# Важно: Добавляем поддержку HEIC/HEIF файлов в filer
# IMAGE_EXTENSIONS это глобальные константы в filer, которые нельзя переопределить через settings.py
# Единственный способ - модифицировать после импорта в ready() методе AppConfig
filer_settings.IMAGE_EXTENSIONS = list(set(filer_settings.IMAGE_EXTENSIONS + ['.heic', '.heif']))
filer_settings.IMAGE_MIME_TYPES = list(set(filer_settings.IMAGE_MIME_TYPES + ['heic', 'heif']))
def patched_save(
self_instance, name: str, content: File, save: bool = True
) -> str | None:
"""
Патчированг MultiStorageFieldFile.save().
Конвертирует загруженные картинки в WebP и обновляет метаданные.
Args:
self_instance: Экземпляр MultiStorageFieldFile
name: Имя файла
content: Содержимое файла
save: Нужно ли сохранять в БД (по умолчанию True)
Returns:
Имя сохраненного файла или None
"""
new_content, new_name, converted, img_width, img_height = (
CustomFilerConfig._convert_to_webp_if_needed(name, content)
)
if converted:
# Переустанавливаем корректный mime_type (всё станет WebP)
self_instance.instance.mime_type = "image/webp"
# Обновляем original_filename если есть
if hasattr(self_instance.instance, 'original_filename'):
if self_instance.instance.original_filename:
original_filename, _ = os.path.splitext(self_instance.instance.original_filename)
self_instance.instance.original_filename = f"{original_filename}.webp"
# КРИТИЧНО: Обновляем размер файла и sha1 на основе WebP данных, а не исходных
new_content.seek(0)
webp_bytes = new_content.read()
new_content.seek(0)
self_instance.instance._file_size = len(webp_bytes)
self_instance.instance.sha1 = hashlib.sha1(webp_bytes).hexdigest()
# Вызываем оригинальный save() метод
result = original_save(self_instance, new_name, new_content, save)
# После сохранения гарантируем что mime_type=image/webp в БД
if converted and new_name.lower().endswith('.webp'):
if self_instance.instance.id:
self_instance.instance.__class__.objects.filter(
id=self_instance.instance.id
).update(mime_type='image/webp')
return result
MultiStorageFieldFile.save = patched_save
@@ -0,0 +1,248 @@
# Generated by Django 6.0.5 on 2026-07-01 15:16
import datetime
import django.db.models.deletion
import filer.fields.file
import filer.fields.image
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('filer', '0019_alter_clipboard_id_alter_clipboarditem_id_and_more'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.CreateModel(
name='TbArticle',
fields=[
('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='Технический заголовок')),
('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='Опубликовано')),
('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='Дата окончания публикации')),
('s_article_title_html', models.CharField(blank=True, default='', help_text='Заголовок статьи, например: "Описание релиза Abbey Road" или "Биография группы The Beatles". Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано, будет отображаться без заголовка.', max_length=255, verbose_name='Заголовок')),
('s_article_teaser_html', models.TextField(blank=True, default='', help_text='Короткий анонс статьи, который будет отображаться в списках. Может содержать HTML-вёрсту (теги, мнемоники, спецсимволы) для типографирования.', null=True, verbose_name='Тизер статьи')),
('s_article_content_html', models.TextField(blank=True, default='', help_text='Полный текст статьи. Может содержать HTML-вёрсту (теги, мнемоники, спецсимволы) для типографирования.', null=True, verbose_name='Статья')),
('i_article_views', models.IntegerField(db_index=True, default=0, verbose_name='Число просмотров')),
('i_article_favorites', models.IntegerField(db_index=True, default=0, verbose_name='Число в избранном')),
('slug', models.SlugField(default='', max_length=255, unique=True, verbose_name='Слаг статьи')),
('seo_title', models.CharField(blank=True, default='', help_text='SEO Title для статьи. Если не указано, будет использоваться заголовок статьи (s_article_title_html) без HTML-тегов.', max_length=255, verbose_name='SEO Title')),
('seo_description', models.CharField(blank=True, default='', help_text='SEO Description для статьи. Если не указано, будет использоваться обрезанный тизер статьи (s_article_teaser_html) без HTML-тегов.', max_length=255, verbose_name='SEO Description')),
('seo_keywords', models.CharField(blank=True, default='', help_text='SEO Keywords для статьи, через запятую. Например: "The Beatles, Abbey Road, Vinyl, 1969"', max_length=255, verbose_name='SEO Keywords')),
('t_article_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_article_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Статья',
'verbose_name_plural': 'Статьи',
'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'),
},
),
migrations.CreateModel(
name='TbArtist',
fields=[
('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='Исполнитель')),
('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_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='Связанная статья')),
],
options={
'verbose_name': 'Исполнитель',
'verbose_name_plural': 'Исполнители',
'ordering': ('s_artist',),
},
),
migrations.CreateModel(
name='TbImage',
fields=[
('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_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='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL')),
('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=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность')),
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
('t_img_updated', models.DateTimeField(auto_now=True, 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={
'verbose_name': 'Изображение',
'verbose_name_plural': 'Изображения',
'ordering': ('-t_img_created', 'i_img_sort'),
},
),
migrations.AddField(
model_name='tbarticle',
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='Изображение для статьи'),
),
migrations.CreateModel(
name='TbLabel',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('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: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <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_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='Связанная статья')),
],
options={
'verbose_name': 'Лейбл',
'verbose_name_plural': 'Лейблы',
'ordering': ('s_label',),
},
),
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='Стиль (канонический)')),
('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(
name='TbOffer',
fields=[
('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='Название оффера')),
('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')),
('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')),
('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='Состояние обложки')),
('f_offer_price', models.DecimalField(db_index=True, decimal_places=2, default=0.0, help_text='Цена в валюте источника. Валюта определяется в TbSource: offer.k_offer_to_source.l_currency', max_digits=10, verbose_name='Цена')),
('i_offer_quantity', models.IntegerField(blank=True, default=0, verbose_name='Количество в наличии')),
('i_offer_discount_to_daily_sale', models.IntegerField(blank=True, db_index=True, default=0, help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано <tt>0</tt> то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', verbose_name='Скидка')),
('j_offer_metadata', models.JSONField(default=dict, help_text='Дополнительные данные о предложении в виде JSON-словаря.', null=True, verbose_name='Дополнительные данные')),
('s_offer_skip32', models.CharField(help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id). Например: "4gfFCJ". Формируется автоматически связкой Skip32 (хаотичное перемешивание) и Base62 (компактная упаковка) из id оффера в методе save().', max_length=12, unique=True, verbose_name='Код товара')),
('i_offer_views', models.IntegerField(db_index=True, default=0, verbose_name='Просмотры')),
('i_offer_favorites', models.IntegerField(db_index=True, default=0, verbose_name='В избранном')),
('t_offer_created', models.DateTimeField(auto_now_add=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_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_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='Лейбл')),
],
options={
'verbose_name': 'Оффер (предложение)',
'verbose_name_plural': 'Офферы (предложения)',
'ordering': ('-t_offer_updated', '-t_offer_created', 's_offer'),
},
),
migrations.CreateModel(
name='TbOfferHistory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('f_history_price', models.DecimalField(blank=True, decimal_places=2, default=0.0, max_digits=12, null=True, verbose_name='Старая цена')),
('i_history_quantity', models.IntegerField(default=0, verbose_name='Старое количество')),
('j_history_metadata', models.JSONField(blank=True, default=dict, help_text='Метаданные, указывающие координаты данных внутри источника (например, внутри Excel-файла: название вкладки, номер строки, номер столбца с ценой и количеством, или URL + CSS-селектор для HTML-страницы и т.п.', verbose_name='Метаданные')),
('t_history_created', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')),
('k_history_to_offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='offer_to_history', to='frontend.tboffer', verbose_name='Оффер')),
],
options={
'verbose_name': 'История оффера',
'verbose_name_plural': 'Истории офферов',
'ordering': ('-t_history_created',),
},
),
migrations.CreateModel(
name='TbSeller',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('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='Тип продавца')),
('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_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_seller_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена картинка, 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='Связанная статья')),
],
options={
'verbose_name': 'Продавец',
'verbose_name_plural': 'Продавцы',
'ordering': ('s_seller',),
},
),
migrations.CreateModel(
name='TbSource',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('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='Тип источника')),
('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 источника')),
('j_source_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла, структуре HTML-страницы и т.п.) в виде JSON-словаря', verbose_name='Дополнительные данные')),
('t_source_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('t_source_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('k_source_to_seller', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='seller_to_source', to='frontend.tbseller', verbose_name='Продавец')),
('source_file', filer.fields.file.FilerFileField(blank=True, help_text='Файл-источник, например, Excel-файл от продавца или издателя. Если данные в источнике представлены на странице в интернете, можно не указывать файл, а указать URL в поле ниже.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.file', verbose_name='Файл-источник')),
],
options={
'verbose_name': 'Источник данных',
'verbose_name_plural': 'Источники данных',
'ordering': ('-t_source_data', '-t_source_created'),
},
),
migrations.AddField(
model_name='tboffer',
name='k_offer_to_source',
field=models.ForeignKey(default=None, help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные продавца: offer.k_offer_to_source.k_source_to_seller', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_to_offer', to='frontend.tbsource', verbose_name='Источник данных'),
),
migrations.AddIndex(
model_name='tbarticle',
index=models.Index(fields=['l_article_type', 'b_article_published', '-t_article_created'], name='idx_articles_by_type_published'),
),
migrations.AddIndex(
model_name='tbofferhistory',
index=models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
),
migrations.AddIndex(
model_name='tboffer',
index=models.Index(fields=['k_offer_to_item', '-f_offer_price'], name='idx_offer_by_item_price'),
),
migrations.AddIndex(
model_name='tboffer',
index=models.Index(fields=['k_offer_to_item', 'i_offer_quantity'], name='idx_offer_by_item_qty'),
),
]
@@ -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',
),
]
@@ -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='Файл изображения'),
),
]
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+419
View File
@@ -0,0 +1,419 @@
# frontend/utils.py
# Служебные функции и хелперы проекта
from __future__ import annotations
import re
import pytils
import random
import logging
from typing import Any, Dict, List, Optional, Union
from bs4 import BeautifulSoup
from html import unescape
from etpgrf.config import HANGING_PUNCTUATION_SPACE_CHARS as SPACE_CHARS
from django.utils.html import mark_safe
from django.http import HttpRequest
from django.db.models import Model
from lpon_site.settings import (
SLUG_MAX_LENGTH, KEY_SYNONYM_EN,
)
logger = logging.getLogger(__name__)
def normalize_string(s: str) -> str:
"""
Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы.
Также приводит к нижнему регистру для case-insensitive сравнения при поиске дублей.
Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.).
Args:
s: Строка для нормализации
Returns:
str: Нормализованная строка в нижнем регистре (или пустая если была пуста)
Пример:
>> normalize_string(" Sony Music ")
'sony music'
>> normalize_string("SONY MUSIC")
'sony music'
>> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами
'sony music'
"""
if not s:
return ""
result = str(s)
# Преобразуем в нижний регистр для case-insensitive сравнения
# (регистр не должен быть значимым при поиске дублей артистов/лейблов/стилей)
result = result.lower()
# Удаляем невидимые символы (не заменять, а полностью удалять)
result = result.translate({
ord("\xad"): None, # символ мягкого переноса
ord("\u200b"): None, # символ нулевой ширины (zero-width space)
ord("\u200c"): None, # символ нулевой ширины (zero-width non-joiner)
ord("\u200d"): None, # символ Zero Width Joiner (ZWJ)
ord("\u2060"): None, # символ Word Joiner (WJ)
ord("\ufeff"): None, # символ Zero Width No-Break Space (BOM)
})
# Все типы пробельных символов для замены на обычный пробел
all_spaces = SPACE_CHARS | frozenset([
"\u00a0", # non-breaking space (&nbsp)
"\u202F", # narrow no-break space (тонкий неразрывный пробел)
])
# Заменяем ВСЕ типы пробелов на обычный пробел
for space_char in all_spaces:
result = result.replace(space_char, " ")
# Удаляем начальные/конечные пробелы и нормализуем множественные пробелы
# Финальная подстраховка: regex для ВСЕХ unicode whitespace символов (даже неизвестных)
result = re.sub(r'\s+', ' ', result).strip()
return result
def super_normalize_string(s: str) -> str:
"""
Супер-нормализация строки для проверки частичного совпадения слов (PARTIAL_MATCH).
Выполняет глубокую нормализацию для сравнения словарных совпадений:
- Очищает HTML через safe_html_special_symbols() (которая вызывает normalize_string())
- Удаляет все символы кроме букв, цифр и пробелов
- Сохраняет буквы и цифры ВСЕХ языков (Unicode-safe)
- Приводит к нижнему регистру (через safe_html_special_symbols → normalize_string)
- Нормализует пробелы (множественные → одиночные)
Результат используется для поиска словарных совпадений:
"The Beatles" и "Beatles, The" оба станут: "the beatles"
Затем разбиваются на слова: ["the", "beatles"]
Args:
s: Строка для нормализации
Returns:
str: Строка со словами, разделенными пробелами (в нижнем регистре)
Пример:
>> super_normalize_string("The Beatles (Rock)")
'the beatles rock'
>> super_normalize_string("Beatles, The - Rock Band!")
'beatles the rock band'
"""
if not s:
return ""
# Сначала очищаем HTML и спецсимволы через существующую функцию
# (safe_html_special_symbols вызывает normalize_string(), который уже приводит к нижнему регистру)
cleaned = safe_html_special_symbols(s)
# Удаляем все символы кроме букв, цифр и пробелов (Unicode-safe)
# \w в Python regex с флагом UNICODE включает: буквы всех языков, цифры и подчеркивание
# Используем [^\w\s] для удаления всего кроме слов и пробелов, затем исключаем подчеркивание
cleaned = re.sub(r'[^\w\s]|_', '', cleaned, flags=re.UNICODE)
# Нормализуем пробелы (множественные → одиночные)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned
def safe_html_special_symbols(s: str) -> str:
"""Преобразует HTML-фрагмент в чистый текст.
Удаляет все HTML-теги и декодирует HTML-мнемоники в Unicode.
Затем нормализует пробелы через normalize_string().
Args:
s: Строка, которую надо очистить (с возможной HTML-разметкой).
Returns:
str: Чистый текст без HTML-разметки, спецсимволов и нормализованный.
Example:
>> safe_html_special_symbols('<p>Привет&nbsp;<b>мир</b>!</p>')
'Привет мир!'
>> safe_html_special_symbols('Текст с\\u00a0неразрывным и \\u202Fтонким пробелом')
'Текст с неразрывным и тонким пробелом'
"""
if not s:
return ""
try:
soup = BeautifulSoup(s, "html.parser")
except Exception as e:
logger.warning(f"BeautifulSoup parse error, using raw string: {e}")
return str(s)
# Скрипты и стили в чистый текст не нужны — выкидываем их целиком.
for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
tag.decompose()
result = soup.get_text()
result = unescape(result)
# Нормализуем: удаляем невидимые символы, все типы пробелов, дубли и края
return normalize_string(result)
def make_slug(
slug_it: str,
max_length: int = SLUG_MAX_LENGTH,
slug_default: str = "content") -> str:
"""Готовит чистый slug из HTML/Unicode текста (включая русский текст).
Преобразует текст в URL-friendly slug:
- Удаляет HTML-теги
- Транслитерирует русский текст
- Удаляет спецсимволы
- Обрезает до max_length
- Гарантирует уникальность (fallback на случайное значение)
Args:
slug_it: Строка для преобразования в slug.
max_length: Максимальная длина slug (по умолчанию из settings).
slug_default: Значение по умолчанию, если slug пустой после обработки.
Returns:
str: Чистый slug, готовый для использования в URL.
Example:
>> make_slug('<b>The Beatles</b>')
'the-beatles'
>> make_slug('Какой-то текст')
'kakoj-to-tekst'
"""
if not slug_it:
return f"{slug_default}-{random.randint(1, 4095):03x}"
# Вычисляем минимальную длину fallback'а: "slug_default-xyz"
min_fallback_length = len(slug_default) + 1 + 3 # "-" и 3 hex-символа
# Очищаем текст от HTML и спецсимволов
clean_text = safe_html_special_symbols(slug_it).lower()
# Транслитерируем и создаем slug (pytils подходит для русского)
slug = pytils.translit.slugify(clean_text)
# Нормализуем множественные дефисы, удаляем дефисы в начале/конце
slug = re.sub(pattern=r"-+", repl="-", string=slug).strip("-")
# Обрезаем излишнее (но только если это не нарушит fallback)
# Если max_length недостаточен для slug_default, не обрезаем
if max_length >= min_fallback_length:
slug = slug[:max_length]
# Если все еще пусто — генерируем fallback (БЕЗ обрезания!)
return slug or f"{slug_default}-{random.randint(1, 4095):03x}"
def update_synonyms_in_metadata(
instance: Model,
main_field_name: str,
metadata_field_name: str,
) -> None:
"""
Обновляет список синонимов в метаданных экземпляра модели.
Универсальный хелпер для управления синонимами во всех моделях (TbLabel, TbArtist, TbMusicStyle и т.д.)
Логика:
- При создании новой записи: добавляет текущее значение поля в SYN_EN
- При редактировании: если значение поля изменилось, добавляет ОБА (старое и новое) в SYN_EN
- Очищает дубликаты в списке синонимов, сохраняя порядок
- Использует KEY_SYN_EN из settings как ключ в metadata словаре
Args:
instance: Экземпляр модели (self из save методе). Обязателен!
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name'). Обязателен!
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata'). Обязателен!
Пример использования в TbLabel.save():
def save(self, *args, **kwargs):
validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata')
update_synonyms_in_metadata(self, 's_label', 'j_label_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
Пример использования в TbArtist.save():
def save(self, *args, **kwargs):
validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata')
update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
"""
model_class = instance.__class__
# Проверяем, что указанные поля существуют в модели
for field_name in [main_field_name, metadata_field_name]:
if not hasattr(instance, field_name):
raise AttributeError(
f"{model_class.__name__} instance has no attribute '{field_name}'. "
f"Check that main_field_name and metadata_field_name are correct."
)
# ===== ОПРЕДЕЛЯЕМ, ЭТО СОЗДАНИЕ ИЛИ РЕДАКТИРОВАНИЕ =====
# Получаем текущее значение основного поля
current_field_value = getattr(instance, main_field_name)
# Определяем новая ли это запись или обновление
is_new = instance.pk is None
# Получаем старое значение поля (для редактирования)
old_field_value = None
if not is_new:
try:
old_instance = model_class.objects.get(pk=instance.pk)
old_field_value = getattr(old_instance, main_field_name)
except model_class.DoesNotExist:
# На случай если что-то пошло не так, считаем это новым
is_new = True
# ===== ИНИЦИАЛИЗИРУЕМ МЕТАДАННЫЕ =====
# Инициализируем metadata если оно пусто
metadata_dict = getattr(instance, metadata_field_name)
if not metadata_dict:
metadata_dict = {}
setattr(instance, metadata_field_name, metadata_dict)
# Убеждаемся, что ключ 'SYNONYM' существует и это список
if KEY_SYNONYM_EN not in metadata_dict or not isinstance(metadata_dict[KEY_SYNONYM_EN], list):
metadata_dict[KEY_SYNONYM_EN] = []
# ===== ДОБАВЛЯЕМ СИНОНИМЫ =====
# Добавляем синонимы при создании ИЛИ если значение поля изменилось
if is_new or old_field_value != current_field_value:
# Если поле было обновлено и значение изменилось - добавляем старое значение
if old_field_value and old_field_value not in metadata_dict[KEY_SYNONYM_EN]:
metadata_dict[KEY_SYNONYM_EN].append(old_field_value)
# Добавляем текущее значение если его еще нет в синонимах
if current_field_value not in metadata_dict[KEY_SYNONYM_EN]:
metadata_dict[KEY_SYNONYM_EN].append(current_field_value)
# ===== ОЧИЩАЕМ ДУБЛИКАТЫ =====
# Удаляем дубликаты в списке синонимов, сохраняя порядок
# (может случиться если пользователь вручную редактировал метаданные)
if KEY_SYNONYM_EN in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM_EN], list):
metadata_dict[KEY_SYNONYM_EN] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM_EN]))
# ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ =====
# Для JSONField нужно явно сообщить что мы изменили содержимое
# иначе Django может не сохранить изменения
setattr(instance, metadata_field_name, metadata_dict)
def create_or_get_related_article(
instance: Model,
article_type: str,
main_field_name: str,
metadata_field_name: str,
related_fk_field_name: str,
) -> Model:
"""
Создает или получает связанную статью для сущности модели.
Универсальный хелпер для создания статей при сохранении лейблов, артистов и т.д.
Алгоритм:
1. Получает значение FK поля напрямую через getattr() (никаких запросов в БД)
2. Если статья найдена → возвращает её
3. Если FK поле пусто → создает новую статью с автоматическими SEO параметрами
Параметры:
-----------
instance : django.db.models.Model
Экземпляр модели (TbLabel, TbArtist, TbMusicStyle и т.д.)
Должен иметь поле k_{type}_to_article для привязки статьи
article_type : TbArticle.ArticleType
Тип статьи (например, TbArticle.ArticleType.LABEL)
main_field_name : str
Имя основного поля сущности (например, 's_label' для TbLabel)
metadata_field_name : str
Имя поля метаданных со словарем синонимов (например, 'j_label_metadata')
related_fk_field_name : str
Имя FK поля в модели instance (например, 'k_label_to_article')
Передается явно из save() модели
Returns:
TbArticle: Созданная или найденная статья
Пример использования в TbLabel.save():
article = create_or_get_related_article(
self,
TbArticle.ArticleType.LABEL,
's_label',
'j_label_metadata',
'k_label_to_article'
)
self.k_label_to_article = article
"""
from .models import TbArticle
# Получаем verbose_name модели для универсальности
verbose_name = instance._meta.verbose_name # "Лейбл", "Артист" и т.д.
# Генерируем slug используя verbose_name (более гибко чем хардкод)
# "Лейбл" → "label", "Артист" → "artist"
article_slug_default = make_slug(slug_it=verbose_name)
# Получаем значение основного поля (например, "Sony Records")
main_field_value = getattr(instance, main_field_name, '')
# ===== ПОПЫТКА НАЙТИ СУЩЕСТВУЮЩУЮ СТАТЬЮ =====
# Получаем статью через FK поле напрямую (никаких запросов в БД)
try:
article = getattr(instance, related_fk_field_name)
if article:
# FK поле содержит статью - возвращаем её
return article
except:
# FK поле не существует или пусто, создаем новую
pass
# ===== СТАТЬЯ НЕ ПРИВЯЗАНА. СОЗДАËМ НОВУЮ СТАТЬЮ =====
# Собираем синонимы из метаданных для SEO ключевых слов и исключаем текущее значение поля
# из списка (оно будет добавлено первым для приоритета)
other_synonyms = [s for s in instance.__dict__.get(metadata_field_name, {}).get(KEY_SYNONYM_EN, []) if str(s) != main_field_value]
# Собираем все синонимы с текущим значением первым
all_synonyms = [main_field_value] + other_synonyms if other_synonyms else [main_field_value]
synonyms_str = ", ".join(str(s) for s in all_synonyms)
# Создаем объект статьи
article = TbArticle(
s_article_title=f"[{verbose_name}] {main_field_value} (auto-make)", # Техническое название для админки
s_article_title_html=main_field_value,
seo_title=main_field_value,
seo_keywords=f"{synonyms_str}, {verbose_name}, {article_slug_default}",
seo_description=f"{main_field_value} ({verbose_name}): информация.",
l_article_type=article_type,
b_article_published=True,
slug=make_slug(slug_it=main_field_value, slug_default=article_slug_default),
)
# Сохраняем статью
article.save()
# TODO: Отправить уведомление в Redis о созданной статье
# Это нужно для парсера и автоматического добавления лейблов/артистов/стилей
# Уведомление должно содержать: article.pk, article.s_article_title_html, model_type, article_type
# Админ должен получить очередь задач "Созданные статьи требуют проверки/редактирования"
# Пример: redis.lpush('parser:created_articles', json.dumps({
# 'pk': article.pk,
# 'title': article.s_article_title_html,
# 'article_type': str(article.l_article_type),
# 'model': instance.__class__.__name__,
# 'created_at': datetime.now().isoformat()
# }))
return article
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for lpon_site project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lpon_site.settings')
application = get_asgi_application()
+358
View File
@@ -0,0 +1,358 @@
"""
Django settings for lpon_site project.
Generated by 'django-admin startproject' using Django 6.0.5.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
import environ
import os
from enum import IntEnum
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Читаем переменные окружения
env = environ.Env()
environ.Env.read_env(os.path.join(BASE_DIR.parent, '.env'))
def _normalize_admin_url(value: str) -> str:
"""Приводит URL админки к виду `segment/` без ведущего слэша."""
normalized = value.strip().lstrip('/')
if not normalized:
return 'admin/'
if not normalized.endswith('/'):
normalized += '/'
return normalized
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('DJANGO_SECRET_KEY', default='3xym$l+!)erah-k23lf0=t=c_4$e0nr*zls&l%pbz@k6v6qn89')
ADMIN_URL = _normalize_admin_url(env('DJANGO_ADMIN_URL', default='admin/'))
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool('DJANGO_DEBUG', default=False)
ALLOWED_HOSTS = env.list(
'DJANGO_ALLOWED_HOSTS',
default=['127.0.0.1', 'localhost', 'testserver', 'lpon.ru'],
)
CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver'])
#########################################
# Настройки сообщений об ошибках когда все упало и т.п.
ADMINS = tuple(
tuple(item.split(':', 1))
for item in env.list('DJANGO_ADMINS', default=['S.Erjemin:erjemin@gmail.com'])
)
# Application definition
INSTALLED_APPS = [
# Django core
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Django-filer и его зависимости
# Порядок важен! polymorphic должен быть ДО filer, easy_thumbnails тоже ДО filer
# 'polymorphic',
'easy_thumbnails',
# 'filer',
# Кастомная надстройка над filer для переопределения verbose_name (и других настроек)
'frontend.apps.CustomFilerConfig',
# Наше приложение
'frontend.apps.FrontendConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'lpon_site.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'lpon_site.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR.parent.joinpath('database', env('DJANGO_SQLITE_NAME', default='lpon-db.sqlite3')),
'CONN_MAX_AGE': 600, # время жизни соединения с базой
'OPTIONS': {
'timeout': 25,
'init_command': "PRAGMA journal_mode=WAL; PRAGMA auto_vacuum=2;",
},
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'ru-RU' #
# TIME_ZONE = 'Etc/GMT+3' #
TIME_ZONE = 'Europe/Moscow' #
USE_I18N = True
USE_TZ = True
FIRST_DAY_OF_WEEK = 1 # неделя начинается с понедельника
DEFAULT_CHARSET = 'utf-8'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
# Локальные каталоги проекта: медиа и статика лежат рядом в `public`.
PUBLIC_DIR = BASE_DIR.parent.joinpath('public') # Папка `public` находится в корне пректа
MEDIA_ROOT = PUBLIC_DIR.joinpath('media')
STATICFILES_DIRS = [PUBLIC_DIR.joinpath('static')]
STATIC_ROOT = PUBLIC_DIR.joinpath('staticfiles')
# ============================================================================
# Django-Filer & Easy-Thumbnails Configuration
# ============================================================================
# Примечание о потоках обработки:
# - Django: количество worker-процессов настраивается через gunicorn/uWSGI (--workers, --threads)
# - Pillow-HEIF: параллелизм настраивается через pillow_heif.options.DECODE_THREADS в apps.py
# - Easy-Thumbnails: использует Pillow напрямую, параллелизм через Pillow
# ============================================================================
FILER_STORAGES = {
'public': {
'main': {
'ENGINE': 'filer.storage.PublicFileSystemStorage',
'OPTIONS': {
'location': MEDIA_ROOT / 'flr',
'base_url': MEDIA_URL + 'flr/',
},
'UPLOAD_TO': 'filer.utils.generate_filename.randomized',
'UPLOAD_TO_PREFIX': '',
},
'thumbnails': {
'ENGINE': 'filer.storage.PublicFileSystemStorage',
'OPTIONS': {
'location': MEDIA_ROOT / 'flrm',
'base_url': MEDIA_URL + 'flrm/',
},
# Переопределяем THUMBNAIL_OPTIONS чтобы убрать prefix 'filer_public_thumbnails'
# По умолчанию filer добавляет 'base_dir': 'filer_public_thumbnails', мы это отключаем
# Почему-то этого нет в документации. :(
'THUMBNAIL_OPTIONS': {
'base_dir': '',
},
},
},
}
# Настройки easy_thumbnails для генерации миниатюр
# Преимущественно используется для django-filer
THUMBNAIL_PRESERVE_FORMAT = False
THUMBNAIL_FORMAT = 'WEBP'
THUMBNAIL_DEBUG = DEBUG
THUMBNAIL_QUALITY= 80
THUMBNAIL_WEBP_QUALITY = THUMBNAIL_QUALITY # Наша, кастомная настройка для WebP-сжатия исходных картинок
THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine'
# Список расширений, которые нужно сохранять при генерации миниатюр
# Это важно для WebP файлов - они не должны быть сконвертированы в другой формат
THUMBNAIL_PRESERVE_EXTENSIONS = ['png', 'gif', 'webp']
# Нейминг миниатюр - стандартный нейминг easy_thumbnails
# Формат: {source_name}{dimensions}{options}{quality}{extra}
# Например: image.webp__40x40_q85_crop_subsampling-2.jpg
THUMBNAIL_NAMER = 'easy_thumbnails.namers.default'
# Показывать ошибки при генерации миниатюр (полезно для отладки)
THUMBNAIL_VERBOSE = DEBUG
THUMBNAIL_ALIASES = {
'': {
# Примечание: filer автоматически генерирует свои миниатюры для админки (40x40, 210x210 и 420x420)
# через DEFAULT_THUMBNAILS (см. BaseImage класс в filer).
'small': {'size': (256, 256), 'crop': True},
'medium': {'size': (512, 512), 'crop': True},
'large': {'size': (1024, 1024), 'crop': 'smart'},
},
}
FILER_UPLOADER_MAX_FILES = 3
FILER_UPLOADER_MAX_FILE_SIZE = 100 * 1024 * 1024
FILER_MAX_IMAGE_PIXELS = 4096 * 4096
FILER_ENABLE_PERMISSIONS = DEBUG
FILER_WHITELIST_FOR_PATH_ACCESS = (
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.heic', '.heif',
'.doc', '.docx', '.pdf', '.xls', '.xlsx', '.txt', '.csv',
)
MIME_TYPE_WHITELIST = (
'image/jpeg', # .jpg / .jpeg
'image/png', 'image/x-png', # .png
'image/gif',
'image/svg+xml',
'image/webp',
'image/heic', 'image/heif', # форматы Apple HEIC/HEIF (без анимации 'image/heic-sequence и 'image/heif-sequence')
'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # .doc / .docx
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xls / .xlsx
'text/plain',
'text/csv',
)
FILE_VALIDATORS = {}
# Настройки для "умной" обрезки изображений
THUMBNAIL_PROCESSORS = (
'easy_thumbnails.processors.colorspace',
'easy_thumbnails.processors.autocrop',
# 'easy_thumbnails.processors.scale_and_crop',
'filer.thumbnail_processors.scale_and_crop_with_subject_location',
'easy_thumbnails.processors.filters',
)
# Конфигурация логирования для отладки загрузок и обработки файлов
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{levelname}] {asctime} {name}:{funcName}() line {lineno} - {message}',
'style': '{',
'datefmt': '%Y-%m-%d %H:%M:%S',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.FileHandler',
'filename': os.path.join(BASE_DIR.parent, 'logs', 'uploads.log'),
'formatter': 'verbose',
},
},
'loggers': {
'frontend.apps': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'frontend.models': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'easy_thumbnails': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
'filer': {
'level': 'DEBUG',
'handlers': ['console', 'file'],
'propagate': False,
},
},
}
# ===================================================
# СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА КОНСТАНТЫ И НАСТРОЙКИ
# Максимальная длина slug: Весь URL оптимально иметь до 70 символов (методичка Google, так как такой URL виден целиком
# на страничке поисковой выдачи, вызывает больше доверия и кликов). С учетом того, что
# в URL входит имя домена и slug-и разделов, оптимально устанавливать slug даже короче
# (20-30 символов максимум).
# SLUG_MAX_LENGTH задает ограничение для slug-ов созданных автоматически. Вручную в admin можно задать до 255 символов.
SLUG_MAX_LENGTH = 60
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
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' # Ключ для хранения заметки о картинке
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'
VALIDATE_KEY__MODEL = 'MODEL'
VALIDATE_KEY__VALUE = 'MATCH_VALUE'
# Типы совпадений как Enum (более информативно чем просто числа, работает в match-case)
class ValidateMatchType(IntEnum):
"""Типы совпадений при поиске дубликатов."""
IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.)
FIND_IN_SYNONYM = 2 # Точное совпадение в синонимах
EXACT_SYNONYM_MATCH = 3 # Точное совпадение синонимов проверяемой записи с синонимами остальных записей
PARTIAL_MATCH = 4 # Схожесть: частичное совпадение (например, часть названия артиста или лейбла в других записях).
# Основного поля и синонимами проверяемой записи и всех остальных записей.
PARTIAL_MATCH__RISK_SHORT_WORDS = 5 # Схожесть: Частичное совпадение, но с короткими словами (например,
# Японская группа "B'Z" или лейбл "XL"), что может вызвать ложные срабатывания
# схожести. Нужны быть аккуратнее, проверять вручную (особенно если короткие
# слова среди синонимов внутри метаданных других записей). Довольно частое
# явление для распростарннеых "суффиксов" MC, DJ, артиклей, инициалов
# исполнителей и т.п.
# Минимальная длина слова для поиска совпадений схожести в синонимах (например, в "The Beatles": "The" не учитывается,
# а "Beatles" учитывается)
MIN_SYNONYM_WORD_LENGTH = 4
+30
View File
@@ -0,0 +1,30 @@
"""
URL configuration for lpon_site project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# Админ-сайт с переименованными приложениями (переопределен в frontend/apps.py)
path(settings.ADMIN_URL, admin.site.urls),
]
# Сервировать медиа-файлы в разработке (в production использовать nginx/Apache)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for lpon_site project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lpon_site.settings')
application = get_wsgi_application()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lpon_site.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
Generated
+906
View File
@@ -0,0 +1,906 @@
# This file is automatically @generated by Poetry 1.8.0 and should not be changed by hand.
[[package]]
name = "asgiref"
version = "3.11.1"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
files = [
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
]
[package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "cssselect2"
version = "0.9.0"
description = "CSS selectors for Python ElementTree"
optional = false
python-versions = ">=3.10"
files = [
{file = "cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563"},
{file = "cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb"},
]
[package.dependencies]
tinycss2 = "*"
webencodings = "*"
[package.extras]
doc = ["furo", "sphinx"]
test = ["pytest", "ruff"]
[[package]]
name = "django"
version = "6.0.5"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.12"
files = [
{file = "django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0"},
{file = "django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269"},
]
[package.dependencies]
asgiref = ">=3.9.1"
sqlparse = ">=0.5.0"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=23.1.0)"]
bcrypt = ["bcrypt (>=4.1.1)"]
[[package]]
name = "django-environ"
version = "0.13.0"
description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application."
optional = false
python-versions = "<4,>=3.9"
files = [
{file = "django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e"},
{file = "django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f"},
]
[package.extras]
develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"]
docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-copybutton", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"]
[[package]]
name = "django-extensions"
version = "3.2.3"
description = "Extensions for Django"
optional = false
python-versions = ">=3.6"
files = [
{file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"},
{file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"},
]
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "django-filer"
version = "3.4.4"
description = "A file management application for django that makes handling of files and images a breeze."
optional = false
python-versions = ">=3.8"
files = [
{file = "django_filer-3.4.4-py3-none-any.whl", hash = "sha256:5df64bb144c861ad2c2697bd6a3709282bda6e14f8623ccd934797474d415c4c"},
{file = "django_filer-3.4.4.tar.gz", hash = "sha256:465eec9fe63310a5b718267beebe7c348ee44afd16c6b5056a7bab49ee483412"},
]
[package.dependencies]
django = ">=3.2"
django-polymorphic = "<5.0"
easy-thumbnails = {version = "*", extras = ["svg"]}
svglib = ">=1.5.1,<1.6.0"
[package.extras]
heif = ["pillow-heif"]
[[package]]
name = "django-polymorphic"
version = "4.11.5"
description = "Seamless polymorphic inheritance for Django models."
optional = false
python-versions = "<4.0,>=3.10"
files = [
{file = "django_polymorphic-4.11.5-py3-none-any.whl", hash = "sha256:8c65b5c109c057c51e059197b01765dd5e096b3fd393ee3038bffc294681b3be"},
{file = "django_polymorphic-4.11.5.tar.gz", hash = "sha256:9a6fe66fab12cfe06aac81942d14670c6b0204aebd30ce9fb58fb53213c613eb"},
]
[package.dependencies]
django = ">=4.2"
typing-extensions = ">=4.12.0"
[[package]]
name = "easy-thumbnails"
version = "2.10.1"
description = "Easy thumbnails for Django"
optional = false
python-versions = ">=3.9"
files = [
{file = "easy_thumbnails-2.10.1-py3-none-any.whl", hash = "sha256:24462d63dd31543ef1585538b2bfefe0db96d3409bb431c70b81548fb2cfc5be"},
{file = "easy_thumbnails-2.10.1.tar.gz", hash = "sha256:a50aa5f99387546c35ab5ba1ea9b3cbbc5658e65601cd34949f62137c32c222e"},
]
[package.dependencies]
django = ">=4.2"
pillow = "*"
reportlab = {version = "*", optional = true, markers = "extra == \"svg\""}
svglib = {version = "*", optional = true, markers = "extra == \"svg\""}
[package.extras]
svg = ["reportlab", "svglib"]
[[package]]
name = "etpgrf"
version = "0.1.6.post1"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false
python-versions = ">=3.10"
files = [
{file = "etpgrf-0.1.6.post1-py3-none-any.whl", hash = "sha256:0863b14385bdacdd405f137ca2ce6bdb6f683f0189e8c927196a1eee754366be"},
{file = "etpgrf-0.1.6.post1.tar.gz", hash = "sha256:984d201cff232a58c05b6f4455a50f822162520df829ad4d543bfe0b7fd962a9"},
]
[package.dependencies]
beautifulsoup4 = ">=4.10.0"
lxml = ">=4.9.0"
regex = ">=2022.1.18"
[[package]]
name = "lxml"
version = "6.1.1"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.8"
files = [
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
{file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
{file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
{file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
{file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
{file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
{file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
{file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
{file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
{file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
{file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
{file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
{file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
{file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
{file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
{file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
{file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
{file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
{file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
{file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
{file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
{file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
{file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
{file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
{file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
{file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
{file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
{file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
]
[package.extras]
cssselect = ["cssselect (>=0.7)"]
html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
[[package]]
name = "pillow"
version = "12.2.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
files = [
{file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
{file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
{file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
{file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
{file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
{file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
{file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
{file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
{file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
{file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
{file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
{file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
{file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
{file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
{file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
{file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
{file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
{file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
{file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
{file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
{file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
{file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
{file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
{file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
{file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
{file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
{file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
{file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
{file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
{file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
{file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
{file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "pillow-heif"
version = "1.3.0"
description = "Python interface for libheif library"
optional = false
python-versions = ">=3.10"
files = [
{file = "pillow_heif-1.3.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:0addc7d25133a2abd0149d1f1b8063808268c9ed75dc228c2196c90d56639a25"},
{file = "pillow_heif-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e557b7082e785ff4505b2be258409fc3983162a3802f5438aa3177201d0e5a41"},
{file = "pillow_heif-1.3.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e031f0036200da349cdac1328a3617a9d13b7f9145355c0a36306ce3b9f1d622"},
{file = "pillow_heif-1.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d00218ce66aa74cddb5cb64e59a8867ed6722cc430b240d578ef3bc1307998a3"},
{file = "pillow_heif-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4c4eef69c7caec0f41af13308bba5883bde751119f27a51c9a299b83852f3430"},
{file = "pillow_heif-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:625b67f7000b3aae645e4aea4170a6f0bb9015577c69cf249c360b10e071e8a7"},
{file = "pillow_heif-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a924fbb505674546973518b8906f499a56bb3332752a144bc272becd59c141"},
{file = "pillow_heif-1.3.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:68bea3b9396fdd6c711e66fc645df92bbe53d48892909192ce9a30e4c619878a"},
{file = "pillow_heif-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a59db0091556d11ab26c2b34532b7992965520027ba0a64084771bcc9a31156"},
{file = "pillow_heif-1.3.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b69cc05b4f22ac57f1fd8f5f7ae96ae7c752036659ea975ec5f5565efadd87f"},
{file = "pillow_heif-1.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a9f26d9ece400f55ac006e2fed079392e44a550023c99122e281fcd72b7c06"},
{file = "pillow_heif-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a25a58368222c388a96b111f4621423eac6fa07a0bbcb2ac5eaf624153cde04e"},
{file = "pillow_heif-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d3cdc335df30652ae9b07438e9898ce5cb5dbc47012fa14f93be1df9d446dc5"},
{file = "pillow_heif-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:b5a1458bc11ca83cd72ec4a93f739ad3ae4315ae66766022ec16d12993a863a4"},
{file = "pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d"},
{file = "pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720"},
{file = "pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221"},
{file = "pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5"},
{file = "pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a"},
{file = "pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b"},
{file = "pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710"},
{file = "pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1"},
{file = "pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379"},
{file = "pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f"},
{file = "pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57"},
{file = "pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e"},
{file = "pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60"},
{file = "pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e"},
{file = "pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07"},
{file = "pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771"},
{file = "pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a"},
{file = "pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142"},
{file = "pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9"},
{file = "pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac"},
{file = "pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7"},
{file = "pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588"},
{file = "pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2"},
{file = "pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb"},
{file = "pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332"},
{file = "pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0"},
{file = "pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba"},
{file = "pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e"},
{file = "pillow_heif-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2723c4b85c5ad0420cb0b3e512ac0aa015e3c8b13013b4738816833aa431f919"},
{file = "pillow_heif-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6bdb197b43a629e2118fd33a9ebcf39abdabe5540b80d8862c53a7611edb42ab"},
{file = "pillow_heif-1.3.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d433048bd23436afa2a33e08fc622712ec97fea1c230d5b5a0fafd5512628c8"},
{file = "pillow_heif-1.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:142b307de087eba33d3174aa29a9669b65a7d006192ef8c98579920be3b56f64"},
{file = "pillow_heif-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:633f5d7fbf60a489ba301fbba06e75539a5cd22ff0b036162db2167803416470"},
{file = "pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f"},
]
[package.dependencies]
pillow = ">=11.1.0"
[package.extras]
dev = ["coverage", "defusedxml", "numpy", "opencv-python (==4.13.0.92)", "packaging", "pre-commit", "pylint", "pympler", "pytest", "setuptools"]
docs = ["sphinx (>=4.4)", "sphinx-issues (>=3.0.1)", "sphinx-rtd-theme (>=1.0)"]
tests = ["defusedxml", "numpy", "packaging", "pympler", "pytest"]
tests-min = ["defusedxml", "packaging", "pytest"]
[[package]]
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pytils"
version = "0.4.4"
description = "Russian-specific string utils"
optional = false
python-versions = "*"
files = [
{file = "pytils-0.4.4-py3-none-any.whl", hash = "sha256:e54c16465a5fdb65d414e2da8045e6cc6de79889acda6143dcef2e1e86a1a840"},
{file = "pytils-0.4.4.tar.gz", hash = "sha256:9992a96caad57daa211584df1da4fd825f11e836d3ed93011785f1d02ab6f0ca"},
]
[[package]]
name = "regex"
version = "2026.5.9"
description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.10"
files = [
{file = "regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44"},
{file = "regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a"},
{file = "regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733"},
{file = "regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2"},
{file = "regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea"},
{file = "regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538"},
{file = "regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2"},
{file = "regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989"},
{file = "regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9"},
{file = "regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00"},
{file = "regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808"},
{file = "regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248"},
{file = "regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6"},
{file = "regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4"},
{file = "regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac"},
{file = "regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03"},
{file = "regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b"},
{file = "regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48"},
{file = "regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8"},
{file = "regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555"},
{file = "regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919"},
{file = "regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451"},
{file = "regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c"},
{file = "regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc"},
{file = "regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d"},
{file = "regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9"},
{file = "regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2"},
{file = "regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf"},
{file = "regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611"},
{file = "regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c"},
{file = "regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994"},
{file = "regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b"},
{file = "regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046"},
{file = "regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06"},
{file = "regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6"},
{file = "regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225"},
{file = "regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0"},
{file = "regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107"},
{file = "regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309"},
{file = "regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8"},
{file = "regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66"},
{file = "regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026"},
{file = "regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962"},
{file = "regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621"},
{file = "regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d"},
{file = "regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce"},
{file = "regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e"},
{file = "regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e"},
{file = "regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070"},
{file = "regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb"},
{file = "regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f"},
{file = "regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c"},
{file = "regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed"},
{file = "regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020"},
{file = "regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2"},
{file = "regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2"},
{file = "regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04"},
{file = "regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c"},
{file = "regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f"},
{file = "regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8"},
{file = "regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6"},
{file = "regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21"},
{file = "regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127"},
{file = "regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca"},
{file = "regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6"},
{file = "regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3"},
{file = "regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6"},
{file = "regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff"},
{file = "regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88"},
{file = "regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178"},
{file = "regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100"},
{file = "regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e"},
{file = "regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2"},
{file = "regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b"},
{file = "regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e"},
{file = "regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041"},
{file = "regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0"},
{file = "regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081"},
{file = "regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5"},
{file = "regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4"},
{file = "regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de"},
{file = "regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a"},
{file = "regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4"},
{file = "regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c"},
{file = "regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9"},
{file = "regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af"},
{file = "regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0"},
{file = "regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4"},
{file = "regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf"},
{file = "regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346"},
{file = "regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676"},
{file = "regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14"},
{file = "regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd"},
{file = "regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e"},
{file = "regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad"},
{file = "regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763"},
{file = "regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372"},
{file = "regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499"},
{file = "regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1"},
{file = "regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d"},
{file = "regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c"},
{file = "regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5"},
{file = "regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20"},
{file = "regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0"},
{file = "regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d"},
{file = "regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b"},
{file = "regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a"},
{file = "regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415"},
{file = "regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2"},
{file = "regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41"},
{file = "regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58"},
{file = "regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77"},
{file = "regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa"},
{file = "regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270"},
]
[[package]]
name = "reportlab"
version = "4.5.1"
description = "The Reportlab Toolkit"
optional = false
python-versions = "<4,>=3.9"
files = [
{file = "reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091"},
{file = "reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494"},
]
[package.dependencies]
charset-normalizer = "*"
pillow = ">=9.0.0"
[package.extras]
accel = ["rl_accel (>=0.9.0,<1.1)"]
bidi = ["rlbidi"]
pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"]
renderpm = ["rl_renderPM (>=4.0.3,<4.1)"]
shaping = ["uharfbuzz"]
[[package]]
name = "soupsieve"
version = "2.8.4"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
files = [
{file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"},
{file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
files = [
{file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"},
{file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"},
]
[package.extras]
dev = ["build"]
doc = ["sphinx"]
[[package]]
name = "svglib"
version = "1.5.1"
description = "A pure-Python library for reading and converting SVG"
optional = false
python-versions = ">=3.7"
files = [
{file = "svglib-1.5.1.tar.gz", hash = "sha256:3ae765d3a9409ee60c0fb4d24c2deb6a80617aa927054f5bcd7fc98f0695e587"},
]
[package.dependencies]
cssselect2 = ">=0.2.0"
lxml = "*"
reportlab = "*"
tinycss2 = ">=0.6.0"
[[package]]
name = "tinycss2"
version = "1.5.1"
description = "A tiny CSS parser"
optional = false
python-versions = ">=3.10"
files = [
{file = "tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661"},
{file = "tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957"},
]
[package.dependencies]
webencodings = ">=0.4"
[package.extras]
doc = ["furo", "sphinx"]
test = ["pytest", "ruff"]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "tzdata"
version = "2026.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
files = [
{file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"},
{file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
]
[[package]]
name = "webencodings"
version = "0.5.1"
description = "Character encoding aliases for legacy web content"
optional = false
python-versions = "*"
files = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "69bad093bc7511b8da08bb4673d5883f7cbeb9b45123b6a17350dd885e2a3a33"
+15
View File
@@ -0,0 +1,15 @@
# CodeMirror 6 static bundle
Эта папка содержит готовый результат сборки CodeMirror 6 для админки Django.
Главный файл здесь — `editor.js`. Его не нужно править вручную: это собранный и
минифицированный бандл.
Если нужна более свежая версия CodeMirror, меняй сборку в
`frontend-assembly/build-codemirror6.sh` и затем заново запускай:
```bash
bash ./frontend-assembly/build-codemirror6.sh
```
После пересборки в этой папке должен обновиться только `editor.js`.
@@ -0,0 +1,78 @@
/**
* CodeMirror 6 - Height Control Patch
*
* Этот патч перехватывает создание CodeMirror редакторов и копирует
* атрибут data-height с textarea на создаваемый wrapper div.
*
* Использование в admin.py:
* textarea widget: attrs={'data-height': '50px', 'data-codemirror-editor': '1'}
*
* Загружается ПОСЛЕ editor.js через Media в Admin класс.
*/
(function () {
'use strict';
/**
* Применяем высоту к уже созданным wrapper'ам
* (CodeMirror уже загрузился и создал обёртки)
*/
function applyHeightsToExistingWrappers() {
// Находим все textarea'ы с data-codemirror-editor
document.querySelectorAll('textarea[data-codemirror-editor]').forEach((textarea) => {
// Соседний div (wrapper, созданный CodeMirror)
const wrapper = textarea.previousElementSibling;
// Проверяем что это действительно wrapper CodeMirror
if (wrapper && wrapper.classList && wrapper.classList.contains('cm6-editor-wrapper')) {
// Копируем data-height на style если указано
if (textarea.dataset.height) {
wrapper.style.height = textarea.dataset.height;
}
// Копируем data-width на style если указано (для ширины)
if (textarea.dataset.width) {
wrapper.style.width = textarea.dataset.width;
wrapper.style.minWidth = textarea.dataset.width;
// Также устанавливаем ширину на сам .cm-editor внутри wrapper
// Используем !important чтобы переопределить flex: 1 1 auto из CSS
const cmEditor = wrapper.querySelector('.cm-editor');
// if (cmEditor) {
// cmEditor.style.cssText = `width: ${textarea.dataset.width} !important; flex: 0 0 auto;`;
// }
}
// Копируем классы с textarea если они есть
// (например cm6-editor-wrapper-xs, cm6-editor-wrapper-sm и т.д.)
if (textarea.className) {
// Добавляем классы из textarea (сохраняя cm6-editor-wrapper)
textarea.className.split(' ').forEach((cls) => {
if (cls && cls !== 'cm6-editor-wrapper') {
wrapper.classList.add(cls);
}
});
}
}
});
}
// Применяем высоты когда DOM готов
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyHeightsToExistingWrappers);
} else {
// DOM уже загружен
applyHeightsToExistingWrappers();
}
// Также применяем для динамически добавленных элементов (если загружают CodeMirror после инициализации)
const observer = new MutationObserver(() => {
applyHeightsToExistingWrappers();
});
observer.observe(document, {
childList: true,
subtree: true,
});
})();
@@ -0,0 +1,79 @@
/* CodeMirror 6 - Стили для правильного отображения */
:root {
color-scheme: light dark;
--brdr: light-dark(#333, #ccc);
--accent-mode-color: light-dark(#222, #eee);
}
.cm6-editor-wrapper {
/* Основной wrapper, созданный CodeMirror */
flex-direction: column;
overflow: hidden;
}
.cm-editor {
/* Сам редактор внутри wrapper - растягиваем на всю доступную высоту */
flex: 1 1 auto;
font-family: monospace;
line-height: 1;
border: 1px solid var(--brdr);;
}
/*
Утилитарные классы для управления шириной.
Мы ищем редактор .cm-editor ВНУТРИ элемента с нашим классом.
*/
.codemirror-width-s > .cm-editor {
/* Маленький (для чисел, коротких ID) */
max-width: 8em !important;
}
.codemirror-width-m > .cm-editor {
/* Средний (для URL, коротких строк) */
max-width: calc(50% - 13em) !important; /* Вычитание 13em подобрано. Это ширина бокового меню слева */
}
.codemirror-width-l > .cm-editor {
/* Большой (для текста, JSON, HTML) */
max-width: calc(75% - 13em) !important;
}
.codemirror-width-xl > .cm-editor {
/* Во всю ширину контейнера */
max-width: calc(100% - 13em) !important;
}
/* Ширина редактора зависит от видимости бокового меню Django Admin.
На экранах < 767px меню прячется, поэтому вычитание 13em неактуально.
Подобрано значение 3ex. */
@media (max-width: 767px) {
.codemirror-width-m > .cm-editor,
.codemirror-width-l > .cm-editor,
.codemirror-width-xl > .cm-editor {
max-width: calc(100% - 3ex) !important;
}
}
/* --- Новый класс для минимальной высоты --- */
.codemirror-min-height-2 .cm-editor {
min-height: 3.5em; /* 5 строк */
}
.codemirror-min-height-5 .cm-editor {
min-height: 8em; /* 5 строк */
}
.codemirror-min-height-10 .cm-editor {
min-height: 16em; /* 10 строк */
}
.codemirror-no-lines .cm-gutters {
/* Скрываем нумерацию строк */
display: none !important;
}
/* Скрыть оригинальный textarea */
textarea[data-codemirror-editor] {
display: none !important;
}
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
/**
* Стили для кнопок при обходе валидации
* Отображает пользователю что валидация была обойдена
*/
/* Кнопки в режиме обхода валидации (при появлении ошибки и предупреждений синонимов и схожести) */
input[type=submit].force-ignore-validation {
background-color: #f39c12 !important; /* Оранжевый/жёлтый цвет */
color: #fff;
}
input[type=submit].force-ignore-validation:hover {
background-color: #e67e22 !important;
cursor: pointer;
transition: background-color 0.3s ease;
}
input[type=submit].force-ignore-validation:not(:hover) {
transition: background-color 0.6s ease;
}
/* Блок красной кнопки подтверждения игнорирования валидации */
.confirmation-button-container {
display: block;
padding-top: 15px;
}
.confirmation-button-container button {
padding: 10px 15px;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.confirmation-button-container button:hover {
background: #c0392b;
transition: background-color 0.6s ease;
}
.confirmation-button-container button:not(:hover) {
transition: background-color 0.6s ease;
}
.confirmation-button-container em {
display: block;
margin-top: 8px;
color: #666;
font-size: 12px;
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Вотчер для отслеживания изменений полей формы и управления режимом обхода валидации.
*
* Используется для валидации с поддержкой игнорирования:
* 1. Добавляет функцию addGetParam для динамического добавления GET параметров к form action
* 2. Отслеживает изменения всех типов полей в форме:
* - Стандартные: input (все типы кроме submit), textarea, select
* - CodeMirror редакторы (div.codemirror)
* - Редактируемое содержимое (contenteditable элементы)
* 3. При изменении данных:
* - Удаляет класс force-ignore-validation ко кнопок (возвращает нормальный цвет)
* - Удаляет GET параметр из action кнопок
* - Скрывает сообщения об ошибках валидации (.errornote, .errorlist)
* 4. Визуально показывает пользователю статус обхода валидации цветом кнопок
*
* Универсальное решение: работает для любых форм в админке, не только для лейблов.
*
* ВАЖНО: Этот скрипт работает независимо от порядка загрузки и инициализации CodeMirror.
* Используется стратегия проверки readyState и MutationObserver для динамического отслеживания
* появления новых элементов, включая CodeMirror div.codemirror.
*/
// Функция для добавления GET параметра к form action кнопки
function addGetParam(button, key, value) {
const form = button.form; // Находим форму, в которой лежит кнопка
const baseAction = form.getAttribute('action'); // Получаем текущий action
// Удаляем старый параметр, если он есть
let cleanAction = baseAction.split('?')[0];
// Проверяем, есть ли уже в action другие GET-параметры
const separator = cleanAction === baseAction ? '?' : '&';
// Динамически прописываем измененный URL в formAction кнопки
button.formAction = baseAction + separator + key + '=' + value;
}
// Функция для добавления класса force-ignore-validation ко всем submit-кнопкам формы
// Используется при клике на кнопку "Я проверил и уверен!"
function markSubmitButtonsToIgnoreValidation() {
// Находим все submit-кнопки на странице и добавляем им класс
// form-field-watcher.js потом отследит добавление класса через MutationObserver
// и добавит соответствующие onclick обработчики
document.querySelectorAll('input[type=submit]').forEach(function (btn) {
btn.classList.add('force-ignore-validation');
});
}
// ===== ОСНОВНАЯ ФУНКЦИЯ ИНИЦИАЛИЗАЦИИ ВОТЧЕРА =====
function initFormWatcher() {
// Находим все submit-кнопки администратора
let submitButtons = document.querySelectorAll('input[type=submit]');
// Если нет submit-кнопок, выходим (не админская форма)
if (submitButtons.length === 0) {
return;
}
// Находим форму
let form = document.querySelector('form');
if (!form) {
return;
}
// Сохраняем оригинальный action формы
let originalAction = form.getAttribute('action');
// Функция для добавления onclick обработчика
function addOnclickHandler(btn) {
// Проверяем есть ли уже onclick
if (!btn.getAttribute('onclick')) {
btn.setAttribute('onclick', 'if (this.classList.contains("force-ignore-validation")) { ' +
'const form = this.form; ' +
'const baseAction = form.getAttribute("action") || ""; ' +
'let cleanAction = baseAction.split("?")[0]; ' +
'const separator = cleanAction === baseAction ? "?" : "&"; ' +
'form.setAttribute("action", baseAction + separator + "ignore_validate=1"); ' +
'}');
}
}
// Функция для удаления onclick обработчика
function removeOnclickHandler(btn) {
btn.removeAttribute('onclick');
}
// Отслеживаем изменения всех типов полей в форме
// ВАЖНО: Используем MutationObserver для динамического отслеживания появления CodeMirror элементов
let watchedInputs = new WeakSet();
function attachWatchersToInputs() {
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
// Функция которая срабатывает при любом изменении
function handleChange() {
// При изменении любого поля:
// 1. Удаляем класс force-ignore-validation (кнопки вернут нормальный цвет)
// 2. Удаляем onclick обработчик
// 3. Восстанавливаем оригинальный action формы
submitButtons.forEach(function (btn) {
if (btn.classList.contains('force-ignore-validation')) {
btn.classList.remove('force-ignore-validation');
removeOnclickHandler(btn);
}
});
form.setAttribute('action', originalAction);
// Скрываем сообщения об ошибках валидации
let errorNotes = document.querySelectorAll('.errornote, .errorlist');
errorNotes.forEach(function (errorElement) {
errorElement.style.display = 'none';
});
}
formInputs.forEach(function (input) {
// Избегаем двойного присоединения слушателей (используем WeakSet)
if (!watchedInputs.has(input)) {
watchedInputs.add(input);
// Слушаем оба события: 'change' для обычных input/select
// и 'input' для CodeMirror и других редакторов
input.addEventListener('change', handleChange);
input.addEventListener('input', handleChange);
}
});
}
// Первый раз присоединяем слушателей к уже существующим элементам
attachWatchersToInputs();
// Мониторим изменение класса force-ignore-validation на кнопках
// Используем MutationObserver для отслеживания добавления/удаления класса
const classObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
let btn = mutation.target;
if (btn.tagName === 'INPUT' && btn.type === 'submit') {
// Если класс добавлен - навешиваем onclick
if (btn.classList.contains('force-ignore-validation')) {
addOnclickHandler(btn);
}
// Если класс удален - удаляем onclick
else if (btn.getAttribute('onclick')) {
removeOnclickHandler(btn);
}
}
}
});
});
// Настраиваем observer для отслеживания изменения класса на кнопках
submitButtons.forEach(function (btn) {
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);
}
}
+26
View File
@@ -0,0 +1,26 @@
[tool.poetry]
name = "lpon-site"
version = "0.1.0"
description = "LPON - platform for selling vinyl records (and related products)"
authors = ["Sergei Erjemin <erjemin@gmail.com>"]
readme = "README.md"
packages = [
{ include = "lpon_site" }
]
[tool.poetry.dependencies]
python = "^3.12"
django = "^6.0"
python-dotenv = "^1.0.0"
django-environ = "^0.13.0"
django-filer = "^3.4.4"
pillow-heif = "^1.3.0"
pytils = "^0.4.4"
etpgrf = "^0.1.6.post1"
[tool.poetry.group.dev.dependencies]
django-extensions = "^3.2"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"