Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc87fa46b | |||
| 140966a830 | |||
| 0681c3d67b | |||
| f43af593ce | |||
| 472eae382d | |||
| 30eb50dde6 | |||
| ee45d3a2fa | |||
| 286dd8eddc | |||
| bd96468dd5 | |||
| 8ea088e26c | |||
| ed40ed1b74 | |||
| af3a2c6690 | |||
| f281997afc | |||
| 70137a6012 | |||
| 67aad9ab11 | |||
| 19e812e231 | |||
| f6267e1e6c | |||
| ca769be110 | |||
| 9522d3fe69 | |||
| 429d2fb417 | |||
| 6dd2254d2c | |||
| 670bfb8c75 | |||
| 22d912608b | |||
| c6d2c388e6 | |||
| acc7662faf | |||
| 855017a0b6 | |||
| be673483ae | |||
| 3928066147 | |||
| 64880da11a | |||
| be542e37ab | |||
| 08bfce2c4c | |||
| 01779a89f0 | |||
| 13ad08e294 | |||
| b316efc425 | |||
| 7b623e975d | |||
| 01710200b2 | |||
| d53330554b | |||
| 8ed61bae13 | |||
| c7844aa3d7 | |||
| 48df2fb32b | |||
| 562b58be47 | |||
| 5b339d1a53 | |||
| 73f0c615f0 | |||
| 8b3fe1fe41 | |||
| e970d59173 | |||
| a30d7f5483 | |||
| afef6b639c | |||
| eceeaaa55b | |||
| 6a178c9c9b | |||
| 3d9f0a6b5d | |||
| 80fef9791c | |||
| 0c3c7bc9ac | |||
| 6d69cd1d41 | |||
| 4a24037ae8 | |||
| 96071cdded | |||
| 57a6e36b33 | |||
| e08694ea82 | |||
| 0cf6363671 | |||
| 23cb31bc40 | |||
| 60700076d1 | |||
| 8b45839c25 | |||
| f2f30bbd0a | |||
| 6d3f5578f1 | |||
| 8f5b2034c7 | |||
| c47a4bc39e | |||
| 7d534ba2a1 | |||
| 5d71ca291d | |||
| de81cc1b34 | |||
| bb915c82e1 | |||
| 7f5ea8b567 | |||
| 989722bf16 | |||
| fed2a1c5a0 | |||
| c7049f7d8d | |||
| 1ea2292833 | |||
| 280383f02d | |||
| 0ad98fec9d | |||
| 71cac55221 | |||
| a5425b212d | |||
| 6d6bb873e9 | |||
| 7fb5b0e44f | |||
| dc94b8a4df | |||
| fa559f2517 | |||
| 7e63fae613 | |||
| 3d301ad326 | |||
| 6a1f923fe4 | |||
| f99bbb5c6b | |||
| 62fe0722e5 | |||
| a6d9f963b3 | |||
| a17906c224 | |||
| d3edbd8315 | |||
| 2e5f8b923e | |||
| 5088cffece | |||
| 7da0e466b3 | |||
| 094983c66a | |||
| f081361c28 | |||
| b0846e13b9 | |||
| e933b4a9b2 | |||
| 48e38fb9cf | |||
| fd70a44c81 | |||
| b2154e815e | |||
| 87ce677945 | |||
| edb7741ffa | |||
| 76e0350688 | |||
| 61be28786b | |||
| dbb6103378 | |||
| eec127912c | |||
| 3181d71227 | |||
| 0741735fa5 | |||
| efd4695960 | |||
| 3f45a7e397 | |||
| be4839ecc2 | |||
| b2b38783cb | |||
| f0502e605f | |||
| 75d359aeb3 | |||
| 5b6c8a8ded | |||
| 5cb05a353f |
+26
-1
@@ -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-файлам.
|
||||
# На локальной машине разработчика (Dev) эта переменная игнорируется.
|
||||
@@ -5,6 +29,7 @@
|
||||
HOST_PROJECT_PATH=/home/user/docker-app/your-site
|
||||
|
||||
|
||||
# Настройки достпа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать
|
||||
# ==============================================
|
||||
# Настройки достпа к пакетам в репозитории, чтобы watchtower мог проверять их свежесть и скачивать
|
||||
REPO_USER=xxxxx
|
||||
REPO_PASS=xxxxx
|
||||
|
||||
+3
-2
@@ -363,7 +363,8 @@ db.sqlite3-wal
|
||||
public/media/*
|
||||
!public/media/README.md
|
||||
staticfiles/
|
||||
public/static/static_collected/
|
||||
public/static_collected/
|
||||
.github/
|
||||
|
||||
# Data Backup
|
||||
database/data.json
|
||||
database/
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Сплеш-экран сайта LPON.RU
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://git.cube2.ru/erjemin/2018-lpon-site)
|
||||
[](https://typograph.cube2.ru/)
|
||||
|
||||
|
||||
## Архив
|
||||
Легаси проекта, который я задумал в 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?** Или еще вопросы/уточнения?
|
||||
@@ -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`.
|
||||
@@ -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 для админки.
|
||||
Executable
+183
@@ -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 'ГОТОВО'
|
||||
Generated
+753
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
),
|
||||
]
|
||||
+65
@@ -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
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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 ( )
|
||||
"\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>Привет <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
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Executable
+22
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user