Compare commits
27 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 |
@@ -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?** Или еще вопросы/уточнения?
|
||||
+724
-283
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-04 12:31
|
||||
# Generated by Django 6.0.5 on 2026-07-01 15:16
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('filer', '0018_alter_file_options'),
|
||||
('filer', '0019_alter_clipboard_id_alter_clipboarditem_id_and_more'),
|
||||
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
|
||||
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: артист, группа или бренд'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')),
|
||||
('l_article_type', models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('label', 'Label: Лейбл, издатель или компания'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')),
|
||||
('b_article_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубликовано')),
|
||||
('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='Дата окончания публикации')),
|
||||
@@ -45,41 +45,12 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TbFormat',
|
||||
fields=[
|
||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||
('s_format', models.CharField(db_index=True, help_text='Название формата носителя, например: "LP", "CD", "Blu-ray", "Compact Cassette", "MiniDisc", "Hi-Fi", "Accessory" и т.д.', max_length=16, unique=True, verbose_name='Формат носителя')),
|
||||
('s_format_slug', models.SlugField(max_length=16, unique=True, verbose_name='Слаг')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Формат носителя',
|
||||
'verbose_name_plural': 'Форматы носителей',
|
||||
'ordering': ('s_format',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TbMusicStyle',
|
||||
fields=[
|
||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||
('s_style_name', models.CharField(db_index=True, help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"', max_length=100, unique=True, verbose_name='Стиль (канонический)')),
|
||||
('s_style_slug', models.SlugField(editable=False, help_text='Автоматически генерируется из названия. Используется в URL и API.', unique=True, verbose_name='Слаг (уникальный идентификатор)')),
|
||||
('j_style_synonyms', models.JSONField(blank=True, default=list, help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]', verbose_name='Синонимы из источников')),
|
||||
('t_style_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('t_style_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Музыкальный стиль',
|
||||
'verbose_name_plural': 'Музыкальные стили',
|
||||
'ordering': ('s_style_name',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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=list, help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles", "Beatles, The"]', null=True, verbose_name='Метаданные JSON')),
|
||||
('j_artist_metadata', models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYNONYM": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON')),
|
||||
('t_artist_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('t_artist_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='Связанная статья')),
|
||||
@@ -96,13 +67,12 @@ class Migration(migrations.Migration):
|
||||
('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='Если изображение взято из внешнего источника (например, Discogs)', null=True, verbose_name='URL источника')),
|
||||
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Cортировка')),
|
||||
('f_img_confidence_score', models.FloatField(blank=True, default=None, help_text='0.0 - 1.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Уверенность (для автоматических данных)')),
|
||||
('s_img_copyright', models.CharField(blank=True, default='', help_text='Например: "© 2024 User" или "CC-BY"', max_length=255, 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(blank=True, help_text='Файл изображения, загруженный через django_filer.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
||||
('image', filer.fields.image.FilerImageField(help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Изображение',
|
||||
@@ -115,32 +85,12 @@ class Migration(migrations.Migration):
|
||||
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='TbItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('s_item', models.CharField(help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".', max_length=128, unique=True, verbose_name='Товар')),
|
||||
('s_item_date', models.CharField(blank=True, default='XXXX-XX-XX', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию. Срабатывает только если t_release_date не указано.', max_length=10, null=True, verbose_name='Дата релиза (str)')),
|
||||
('t_item_date', models.DateField(blank=True, help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', null=True, verbose_name='Дата релиза')),
|
||||
('i_discogs_master_id', models.IntegerField(blank=True, default=None, help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: <tt>306323</tt>', null=True, verbose_name='ID на мастер-релиз Discogs')),
|
||||
('j_item_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', null=True, verbose_name='Дополнительные данные')),
|
||||
('t_item_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('t_item_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('k_item_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||
('k_item_to_artist', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько для коллабораций', related_name='artist_to_item', to='frontend.tbartist', verbose_name='Исполнители')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Товар в каталоге (релиз, носитель, аксессуар)',
|
||||
'verbose_name_plural': 'Товары в каталоге',
|
||||
'ordering': ('s_item',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TbLabel',
|
||||
fields=[
|
||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||
('s_label', models.CharField(help_text='Техническое название лейбла. Например: "Sony Records" или "Мелодия"', max_length=128, unique=True, verbose_name='Лейбл')),
|
||||
('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д.', null=True, verbose_name='Метаданные')),
|
||||
('s_label', models.CharField(help_text='Название лейбла. Например: "Sony Records" или "Мелодия"... Представление на самом сайте, с версткой, будет определиться через статью, связанную с лейблом (TbArticle).', max_length=128, unique=True, verbose_name='Лейбл')),
|
||||
('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <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='Связанная статья')),
|
||||
@@ -151,18 +101,52 @@ class Migration(migrations.Migration):
|
||||
'ordering': ('s_label',),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tbarticle',
|
||||
name='k_article_to_styles',
|
||||
field=models.ManyToManyField(blank=True, db_index=True, help_text='Стили этой статьи/артиста/релиза (Rock, Jazz, Classical, ...)', related_name='style_to_article', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили'),
|
||||
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')),
|
||||
('d_offer_date_release', models.DateField(blank=True, default=None, help_text='Дата релиза, если она известна (например, дата выпуска переиздания)', null=True, verbose_name='Дата релиза')),
|
||||
('b_offer_is_preorder', models.BooleanField(db_index=True, default=False, help_text='Если товар доступен только для предзаказа', verbose_name='Предзаказ')),
|
||||
('d_offer_date_release', models.DateField(db_index=True, default=None, help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', null=True, verbose_name='Дата релиза')),
|
||||
('i_offer_discogs_id', models.IntegerField(blank=True, default=0, help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>', verbose_name='ID на релиз Discogs')),
|
||||
('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='Состояние обложки')),
|
||||
@@ -176,7 +160,6 @@ class Migration(migrations.Migration):
|
||||
('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_format', models.ManyToManyField(blank=True, db_index=True, help_text='Форматы носителей (пластинка, CD, кассета и т.п.). Можно выбрать несколько.', related_name='format_to_offer', to='frontend.tbformat', verbose_name='Форматы')),
|
||||
('k_offer_to_image', models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.', related_name='image_to_offer', to='frontend.tbimage', verbose_name='Изображения')),
|
||||
('k_offer_to_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='Лейбл')),
|
||||
@@ -208,6 +191,7 @@ class Migration(migrations.Migration):
|
||||
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='Дата создания')),
|
||||
@@ -224,9 +208,8 @@ class Migration(migrations.Migration):
|
||||
name='TbSource',
|
||||
fields=[
|
||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||
('l_source_currency', models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('try', 'TRY: турецкая лира'), ('amd', 'AMD: армянский драм'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника')),
|
||||
('s_source_name', models.CharField(blank=True, default='', help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>', max_length=128, verbose_name='Название источника')),
|
||||
('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')),
|
||||
('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое (включая ручной ввод)')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')),
|
||||
('t_source_data', models.DateField(blank=True, default=datetime.date.today, help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', verbose_name='Дата данных')),
|
||||
('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='Дополнительные данные')),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Generated by Django 6.0.5 on 2026-07-10 07:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import filer.fields.image
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tbarticle',
|
||||
name='k_article_to_image',
|
||||
field=models.ForeignKey(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.</br><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbartist',
|
||||
name='j_artist_metadata',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbartist',
|
||||
name='k_artist_to_article',
|
||||
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbitem',
|
||||
name='k_item_to_article',
|
||||
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tblabel',
|
||||
name='j_label_metadata',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <tt>{"SYN_EN": ["Island", "Island Records", "Vertigo France"]}</tt>.', null=True, verbose_name='Метаданные'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tblabel',
|
||||
name='k_label_to_article',
|
||||
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbmusicstyle',
|
||||
name='j_style_metadata',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.', verbose_name='Метаданные'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tboffer',
|
||||
name='k_offer_to_image',
|
||||
field=models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара/предложения. Порядок определяется полем i_img_sort в TbImageMetadata. Доступ к метаданным: image.metadata.i_img_sort и image.metadata.j_img_metadata', related_name='offer_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbseller',
|
||||
name='k_seller_to_article',
|
||||
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_seller', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TbImageMetadata',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка')),
|
||||
('j_img_metadata', models.JSONField(blank=True, default=dict, help_text='JSON с дополнительными данными: источник (парсер, админка), тип (реальное/абстрактное), достоверность, URL источника и т.д. Гибкое хранилище, позволяет добавлять новые поля без миграций БД.', null=True, verbose_name='Метаданные')),
|
||||
('l_img_source', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Источник')),
|
||||
('l_img_reality', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Тип снимка')),
|
||||
('s_img_src_url', models.URLField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] URL')),
|
||||
('f_img_confidence_score', models.FloatField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] Достоверность')),
|
||||
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='[DEPRECATED] Дата добавления')),
|
||||
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='[DEPRECATED] Дата обновления')),
|
||||
('image', filer.fields.image.FilerImageField(help_text='Файл изображения из django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, related_name='metadata', to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Метаданные изображения',
|
||||
'verbose_name_plural': 'Метаданные изображений',
|
||||
'ordering': ('i_img_sort',),
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TbImage',
|
||||
),
|
||||
]
|
||||
@@ -1,54 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-12 10:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import filer.fields.image
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='tboffer',
|
||||
name='k_offer_to_format',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tbimage',
|
||||
name='s_img_copyright',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tboffer',
|
||||
name='l_offer_to_format',
|
||||
field=models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"formats": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbimage',
|
||||
name='f_img_confidence_score',
|
||||
field=models.FloatField(blank=True, default=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbimage',
|
||||
name='i_img_sort',
|
||||
field=models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbimage',
|
||||
name='image',
|
||||
field=filer.fields.image.FilerImageField(default=1, help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbimage',
|
||||
name='s_img_src_url',
|
||||
field=models.URLField(blank=True, help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='TbFormat',
|
||||
),
|
||||
]
|
||||
+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='Файл изображения'),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-12 12:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0002_remove_tboffer_k_offer_to_format_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='tbsource',
|
||||
name='l_source_currency',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tbseller',
|
||||
name='l_seller_currency',
|
||||
field=models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('amd', 'AMD: армянских драм'), ('try', 'TRY: турецкая лира'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника'),
|
||||
),
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-13 18:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('frontend', '0003_remove_tbsource_l_source_currency_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='tbarticle',
|
||||
name='k_article_to_styles',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tbmusicstyle',
|
||||
name='s_style_slug',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tbitem',
|
||||
name='k_item_to_style',
|
||||
field=models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.', related_name='style_to_item', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tbmusicstyle',
|
||||
name='k_style_to_article',
|
||||
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья о музыкальном стиле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_style', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbarticle',
|
||||
name='l_article_type',
|
||||
field=models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tbsource',
|
||||
name='l_source_type',
|
||||
field=models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое (включая ручной ввод)')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника'),
|
||||
),
|
||||
]
|
||||
+327
-92
@@ -84,7 +84,7 @@
|
||||
# │ PK: id │ SmallAutoField
|
||||
# │ s_style_name │ Название (Rock, Jazz, Classical...)
|
||||
# │ k_style_to_article│ 1:1 OneToOne FK → TbArticle (для SEO, слага, контента)
|
||||
# │ j_style_synonyms │ JSON синонимы из Discogs для матчинга при импорте
|
||||
# │ j_style_metadata │ JSON синонимы из Discogs для матчинга при импорте
|
||||
# │ t_style_created │ Timestamp
|
||||
# │ t_style_updated │ Timestamp
|
||||
# │ │ ⬆ Индексы: id (+), related_name→article_to_style
|
||||
@@ -141,6 +141,8 @@
|
||||
# │ k_offer_to_source │ FK → TbSource [indexed]
|
||||
# │ k_offer_to_article │ FK → TbArticle (опционально)
|
||||
# │ k_offer_to_image │ M2M → TbImage (несколько фото)
|
||||
# │ b_offer_is_preorder │ Предзаказ (bool, indexed)
|
||||
# │ d_offer_date_release │ Дата релиза/ожидаемого выхода по предзаказу [indexed]
|
||||
# │ l_offer_condition_media │ Состояние (s, m, nm, vg, g, f, p)
|
||||
# │ l_offer_condition_sleeve │ Состояние (s, m, nm, vg, g, f, p)
|
||||
# │ f_offer_price │ Цена [indexed для сортировки]
|
||||
@@ -151,7 +153,7 @@
|
||||
# │ i_offer_favorites │ Счетчик в избранном
|
||||
# │ t_offer_created │ Timestamp
|
||||
# │ t_offer_updated │ Timestamp
|
||||
# │ │ ⬆ Индексы: (item, price↓), (item, quantity), (source, discount)
|
||||
# │ │ ⬆ Индексы: (item, price↓), (item, quantity), (source, discount), (is_preorder), (date_release)
|
||||
# │ │ ⬆ Constraint UNIQUE: (item, source, format)
|
||||
# └────────────────────┬───────────┘
|
||||
# │
|
||||
@@ -191,7 +193,8 @@
|
||||
# │ k_item_to_artist │ M2M → TbArtist (для коллабораций)
|
||||
# │ k_item_to_style │ M2M → TbMusicStyle (жанры альбома)
|
||||
# │ k_item_to_article │ 1:1 FK → TbArticle (content, SEO, slug)
|
||||
# │ t_item_date │ Дата релиза
|
||||
# │ s_item_date │ Дата релиза (строка, неполная/текстовая) [indexed]
|
||||
# │ t_item_date │ Базовая дата релиза товара/альбома [indexed]
|
||||
# │ i_discogs_master_id │ ID мастер-релиза на Discogs
|
||||
# │ t_item_created │ Timestamp
|
||||
# │ t_item_updated │ Timestamp
|
||||
@@ -237,88 +240,99 @@
|
||||
# - M2M использует числовые FK (INT) вместо строк
|
||||
# - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from filer.fields.image import FilerImageField
|
||||
from filer.fields.file import FilerFileField
|
||||
from frontend.utils import make_slug, validate_and_raise_for_duplicates, update_synonyms_in_metadata, create_or_get_related_article
|
||||
import datetime
|
||||
import logging
|
||||
from frontend.utils import make_slug, update_synonyms_in_metadata, create_or_get_related_article
|
||||
from frontend.utils_validators import validate_and_raise_for_duplicates
|
||||
from lpon_site.settings import (
|
||||
KEY_IMAGE_TYPE, # Ключ, о том, какой тип изображения (реальное или абстрактное)
|
||||
VALUE_IMAGE_ABSTRACT, # Реальная картинка (актуально, для фото товаров в TbOffer)
|
||||
KEY_IMAGE_FROM, # Ключ, о том, как получено изображение
|
||||
VALUE_IMAGE_FROM_USER, # Картинка загружена пользователем через админку
|
||||
KEY_IMAGE_URL, # Ключ, о том, откуда получена картинка (URL источника)
|
||||
KEY_IMAGE_NOTE, # Ключ, для заметок о картинке (например, "обложка", "задник", "вкладка" и т.д.)
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ИЗОБРАЖЕНИЯ
|
||||
# МЕТАДАННЫЕ ИЗОБРАЖЕНИЙ
|
||||
# ============================================================================
|
||||
class TbImage(models.Model):
|
||||
class TbImageMetadata(models.Model):
|
||||
"""
|
||||
Изображение, связанное с релизом, оффером, исполнителем и т.д.
|
||||
"""
|
||||
# Источник изображения
|
||||
class ImageSource(models.TextChoices):
|
||||
PARSER_UPLOAD = 'parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'
|
||||
MANUAL_UPLOAD = 'manual', 'Ручная загрузка пользователем'
|
||||
VENDOR = 'vendor', 'От продавца'
|
||||
OTHER = 'other', 'Другое'
|
||||
Метаданные к изображениям из django_filer.
|
||||
|
||||
# Тип изображения (реальное или абстрактное)
|
||||
class ImageReality(models.TextChoices):
|
||||
REAL_PHOTO = 'real', 'Реальная фотография товара'
|
||||
ABSTRACT = 'abstract', 'Абстрактное (из внешнего источника)'
|
||||
Промежуточная таблица для M2M связи TbOffer ↔ filer.Image.
|
||||
Хранит дополнительные поля:
|
||||
- i_img_sort: порядок сортировки изображений (0=обложка, 1=задник, 2=вкладка и т.д.)
|
||||
- j_img_metadata: JSON с гибкими данными (источник, тип, достоверность и т.д.)
|
||||
- m_offer: FK на TbOffer (часть M2M через эту промежуточную таблицу)
|
||||
- image: FK на filer.Image (часть M2M через эту промежуточную таблицу)
|
||||
|
||||
Данные можно заполнять вручную через админку или программно из парсеров.
|
||||
|
||||
Доступ в коде:
|
||||
- Все картинки офера (отсортированные): offer.m_image.all().order_by('i_img_sort')
|
||||
- Все картинки для изображения: image.m_offer.all()
|
||||
- Прямой доступ к данным: tbimagemetadata.image, tbimagemetadata.m_offer, tbimagemetadata.i_img_sort, tbimagemetadata.j_img_metadata
|
||||
"""
|
||||
|
||||
image = FilerImageField(
|
||||
# Файл через django_filer
|
||||
# Связь в M2M с filer.Image (часть промежуточной таблицы)
|
||||
# CASCADE: если удалится картинка → удалить метаданные (метаданные без картинки бессмысленны)
|
||||
null=False,
|
||||
blank=False,
|
||||
on_delete=models.DO_NOTHING,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='m_offer', # Встречный доступ: filer_image.m_offer.all()
|
||||
verbose_name='Файл изображения',
|
||||
help_text='Файл изображения, загруженный через django_filer.',
|
||||
)
|
||||
l_img_source = models.CharField(
|
||||
max_length=10,
|
||||
choices=ImageSource.choices,
|
||||
default=ImageSource.MANUAL_UPLOAD,
|
||||
verbose_name='Источник',
|
||||
help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например,'
|
||||
' Discogs), предоставлен продавцом и т.д.',
|
||||
)
|
||||
l_img_reality = models.CharField(
|
||||
max_length=10,
|
||||
choices=ImageReality.choices,
|
||||
default=ImageReality.ABSTRACT,
|
||||
verbose_name='Тип снимка',
|
||||
help_text='Реальная фотография товара или картинка из внешнего источника?',
|
||||
)
|
||||
s_img_src_url = models.URLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='URL',
|
||||
help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)',
|
||||
help_text='Файл изображения из django_filer.',
|
||||
)
|
||||
|
||||
i_img_sort = models.IntegerField(
|
||||
# Порядок (сортировка) вывода
|
||||
# Порядок (сортировка) вывода изображений
|
||||
default=0,
|
||||
db_index=True,
|
||||
verbose_name='Сортировка',
|
||||
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать'
|
||||
' для указания обложки (0), задника (1) и т.д.',
|
||||
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. '
|
||||
'Можно использовать для указания обложки (0), задника (1) и т.д.',
|
||||
)
|
||||
f_img_confidence_score = models.FloatField(
|
||||
# Доверие данным (для парсеров и API)
|
||||
|
||||
j_img_metadata = models.JSONField(
|
||||
# Гибкие дополнительные данные об изображении с предзаполненной структурой
|
||||
# Пример: {"IMG_IS": "abstract", "IMG_FROM": "ivan", "IMG_URL": None, "IMG_NOTE": "обложка"}
|
||||
default={
|
||||
KEY_IMAGE_TYPE: VALUE_IMAGE_ABSTRACT,
|
||||
KEY_IMAGE_FROM: VALUE_IMAGE_FROM_USER,
|
||||
KEY_IMAGE_URL: None,
|
||||
KEY_IMAGE_NOTE: '???', # Автогенерация через парсер или админку
|
||||
},
|
||||
blank=True,
|
||||
null=False, # Изменено с True на False т.к. теперь всегда есть структура default
|
||||
verbose_name='Метаданные',
|
||||
help_text='JSON с дополнительными данными: тип (реальное/абстрактное), источник (парсер/админка), '
|
||||
'URL источника и заметки. Предзаполнено структурой для удобства.',
|
||||
)
|
||||
|
||||
# Связь с TbOffer (часть M2M через эту промежуточную таблицу)
|
||||
m_offer = models.ForeignKey(
|
||||
'TbOffer',
|
||||
on_delete=models.SET_NULL, # SET_NULL: если удалится оффер → метаданные остаются (картинка же еще есть!)
|
||||
null=True,
|
||||
blank=True,
|
||||
default=10.0,
|
||||
verbose_name='Достоверность',
|
||||
help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение',
|
||||
related_name='m_image', # Встречный доступ: offer.m_image.all()
|
||||
verbose_name='Оффер',
|
||||
help_text='Оффер, к которому привязана эта картинка (может быть пусто, если оффер удален)',
|
||||
)
|
||||
t_img_created = models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления',)
|
||||
t_img_updated = models.DateTimeField(auto_now=True, verbose_name='Дата обновления',)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Изображение'
|
||||
verbose_name_plural = 'Изображения'
|
||||
ordering = ('-t_img_created', 'i_img_sort',)
|
||||
verbose_name = 'Метаданные изображения'
|
||||
verbose_name_plural = 'Метаданные изображений'
|
||||
ordering = ('i_img_sort',)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -390,14 +404,20 @@ class TbArticle(models.Model):
|
||||
' Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано,'
|
||||
' будет отображаться без заголовка.'
|
||||
)
|
||||
k_article_to_image = models.ForeignKey(
|
||||
TbImage,
|
||||
k_article_to_image = FilerImageField(
|
||||
# Метаданные изображения (сортировка, источник, тип и т.д.) находятся в TbImageMetadata.
|
||||
# Если нужно получить метаданные изображения, можно использовать `article.k_article_to_image.metadata`.
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='image_to_article',
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||||
db_index=True,
|
||||
verbose_name='Изображение для статьи',
|
||||
help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer).'
|
||||
' Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.</br>'
|
||||
'<b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям,'
|
||||
' её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае '
|
||||
' рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).'
|
||||
)
|
||||
s_article_teaser_html = models.TextField(
|
||||
blank=True,
|
||||
@@ -422,7 +442,7 @@ class TbArticle(models.Model):
|
||||
verbose_name='Число просмотров',
|
||||
)
|
||||
i_article_favorites = models.IntegerField(
|
||||
# Счетчик добавлений в избранное (включая избранное артиста, итема/релиза/товара, лейбла и продавца)
|
||||
# Счетчик добавлений в избранное (включая избранный артист, item/релиз/товар/лейбл/продавец)
|
||||
default=0,
|
||||
db_index=True, # для сортировки "самые добавляемые в избранное"
|
||||
verbose_name='Число в избранном',
|
||||
@@ -480,6 +500,7 @@ class TbArticle(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Автоматически генерируем slug на основе заголовка статьи.
|
||||
Если привязана картинка и метаданных нет — создаем TbImageMetadata с тегами IMG_IS и IMG_FROM.
|
||||
Вызывается при каждом сохранении записи (создание или обновление).
|
||||
"""
|
||||
# Если slug не установлен (новая запись) — генерируем его из названия
|
||||
@@ -497,8 +518,72 @@ class TbArticle(models.Model):
|
||||
|
||||
self.slug = slug
|
||||
|
||||
# Сохраняем статью в БД (нужно чтобы потом привязать метаданные картинки)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Если картинка привязана и метаданных для неё нет — создаем их
|
||||
if self.k_article_to_image:
|
||||
# Получаем существующие метаданные (если есть)
|
||||
metadata = TbImageMetadata.objects.filter(image=self.k_article_to_image).first()
|
||||
|
||||
adding_metadata = {}
|
||||
if not metadata or not KEY_IMAGE_TYPE in (metadata.j_img_metadata or {}):
|
||||
adding_metadata[KEY_IMAGE_TYPE] = VALUE_IMAGE_ABSTRACT
|
||||
if not metadata or not KEY_IMAGE_FROM in (metadata.j_img_metadata or {}):
|
||||
# Получаем username из контекста админки (установлен в ArticleAdmin.save_model())
|
||||
# Fallback на 'unknown' если username недоступен (например, при программном создании)
|
||||
username = getattr(self, '_admin_username', 'unknown')
|
||||
adding_metadata[KEY_IMAGE_FROM] = username
|
||||
if not metadata or not KEY_IMAGE_URL in (metadata.j_img_metadata or {}):
|
||||
adding_metadata[KEY_IMAGE_URL] = None
|
||||
if not metadata or not KEY_IMAGE_NOTE in (metadata.j_img_metadata or {}):
|
||||
adding_metadata[KEY_IMAGE_NOTE] = None
|
||||
if not metadata:
|
||||
metadata = TbImageMetadata.objects.create(
|
||||
image=self.k_article_to_image,
|
||||
i_img_sort=0,
|
||||
j_img_metadata=adding_metadata
|
||||
)
|
||||
else:
|
||||
# Обновляем только недостающие ключи в j_img_metadata
|
||||
if adding_metadata:
|
||||
metadata.j_img_metadata.update(adding_metadata)
|
||||
metadata.save(update_fields=['j_img_metadata'])
|
||||
|
||||
# Заполняем поля filer.Image если они пусты
|
||||
# Это улучшает SEO и accessibility (для скринридеров, поисковиков)
|
||||
image_needs_update = False
|
||||
|
||||
# default_alt_text (alt) — компактное описание для скринридеров и SEO
|
||||
if not self.k_article_to_image.default_alt_text:
|
||||
# Приоритет: KEY_IMAGE_NOTE → seo_title (оптимизирован для SEO) → fallback
|
||||
alt_text = (
|
||||
metadata.j_img_metadata.get(KEY_IMAGE_NOTE)
|
||||
or self.seo_title
|
||||
or self.s_article_title[:60]
|
||||
)
|
||||
self.k_article_to_image.default_alt_text = alt_text
|
||||
image_needs_update = True
|
||||
|
||||
# default_caption (title) — подробное описание с контекстом
|
||||
if not self.k_article_to_image.default_caption:
|
||||
# Используем seo_description (оптимизирован для SEO) с добавлением типа и источника
|
||||
caption = self.seo_description or self.s_article_title[:80]
|
||||
# Добавляем метаинформацию для полноты
|
||||
caption = (
|
||||
f"{caption}. "
|
||||
f"Type: {metadata.j_img_metadata[KEY_IMAGE_TYPE]}. "
|
||||
f"Source: {metadata.j_img_metadata[KEY_IMAGE_FROM]}"
|
||||
)
|
||||
self.k_article_to_image.default_caption = caption
|
||||
image_needs_update = True
|
||||
|
||||
# Сохраняем картинку, если были изменения
|
||||
if image_needs_update:
|
||||
self.k_article_to_image.save()
|
||||
|
||||
# Примечание: m_offer остаётся NULL т.к. это статья, а не оффер
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Статья'
|
||||
verbose_name_plural = 'Статьи'
|
||||
@@ -548,12 +633,12 @@ class TbMusicStyle(models.Model):
|
||||
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.'
|
||||
)
|
||||
j_style_synonyms = models.JSONField(
|
||||
default=list,
|
||||
j_style_metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name='Синонимы из источников',
|
||||
help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||||
' Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]',
|
||||
verbose_name='Метаданные',
|
||||
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||||
' Пример: <tt>{"SYN_EN": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.',
|
||||
)
|
||||
t_style_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания")
|
||||
t_style_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления")
|
||||
@@ -561,6 +646,42 @@ class TbMusicStyle(models.Model):
|
||||
def __str__(self):
|
||||
return self.s_style_name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Переопределяем save для управления синонимами музыкального стиля и создания связанной статьи.
|
||||
|
||||
При сохранении музыкального стиля (создание и обновление):
|
||||
1. Управляем синонимами:
|
||||
- Для новых музыкальных стилей: добавляем текущий s_style_name в SYN_EN
|
||||
- При изменении s_style_name: добавляем как старый, так и новый s_style_name в SYN_EN
|
||||
- При редактировании: используем j_style_metadata из формы (приоритет админу)
|
||||
2. Если статья не привязана - создаём новую статью музыкального стиля автоматически
|
||||
3. Генерируем технический заголовок и slug для статьи
|
||||
"""
|
||||
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
|
||||
# Проверяем ДО работы с синонимами и метаданными!
|
||||
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
|
||||
validate_and_raise_for_duplicates(self, 's_style_name', 'j_style_metadata')
|
||||
|
||||
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
|
||||
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
|
||||
update_synonyms_in_metadata(self, 's_style_name', 'j_style_metadata')
|
||||
|
||||
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
|
||||
# Используем универсальный хелпер для создания/поиска статьи
|
||||
# Хелпер сам проверит через обратный FK. Не дублирует статьи, даже если админ переименовал
|
||||
article = create_or_get_related_article(
|
||||
self,
|
||||
TbArticle.ArticleType.STYLE,
|
||||
's_style_name',
|
||||
'j_style_metadata',
|
||||
'k_style_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
|
||||
)
|
||||
self.k_style_to_article = article
|
||||
|
||||
# Вызываем оригинальный save родительского класса
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Музыкальный стиль'
|
||||
verbose_name_plural = 'Музыкальные стили'
|
||||
@@ -592,16 +713,16 @@ class TbArtist(models.Model):
|
||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||
verbose_name='Связанная статья',
|
||||
help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же'
|
||||
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
' через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.'
|
||||
)
|
||||
j_artist_metadata = models.JSONField(
|
||||
default=list,
|
||||
default=dict,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Метаданные JSON',
|
||||
help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles",'
|
||||
' "Beatles, The"]',
|
||||
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||||
' Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.',
|
||||
)
|
||||
t_artist_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
|
||||
t_artist_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",
|
||||
@@ -610,6 +731,42 @@ class TbArtist(models.Model):
|
||||
def __str__(self):
|
||||
return f"artist {self.id:0>4}: {self.s_artist}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Переопределяем save для управления синонимами исполнителей и создания связанной статьи.
|
||||
|
||||
При сохранении исполнителя (создание и обновление):
|
||||
1. Управляем синонимами:
|
||||
- Для новых исполнителей: добавляем текущий s_artist в SYN_EN
|
||||
- При изменении s_artist: добавляем как старый, так и новый s_artist в SYN_EN
|
||||
- При редактировании: используем j_artist_metadata из формы (приоритет админу)
|
||||
2. Если статья не привязана - создаём новую статью исполнителя автоматически
|
||||
3. Генерируем технический заголовок и slug для статьи
|
||||
"""
|
||||
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
|
||||
# Проверяем ДО работы с синонимами и метаданными!
|
||||
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
|
||||
validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata')
|
||||
|
||||
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
|
||||
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
|
||||
update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata')
|
||||
|
||||
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
|
||||
# Используем универсальный хелпер для создания/поиска статьи
|
||||
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
|
||||
article = create_or_get_related_article(
|
||||
self,
|
||||
TbArticle.ArticleType.ARTIST,
|
||||
's_artist',
|
||||
'j_artist_metadata',
|
||||
'k_artist_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
|
||||
)
|
||||
self.k_artist_to_article = article
|
||||
|
||||
# Вызываем оригинальный save родительского класса
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Исполнитель'
|
||||
verbose_name_plural = 'Исполнители'
|
||||
@@ -655,21 +812,20 @@ class TbItem(models.Model):
|
||||
TbArticle,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='article_to_item',
|
||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||||
db_index=True,
|
||||
default=None,
|
||||
null=True,
|
||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||
blank=True,
|
||||
verbose_name='Связанная статья',
|
||||
help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
|
||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.'
|
||||
)
|
||||
s_item_date = models.CharField(
|
||||
# Год выпуска (важно для коллекционеров). Так же в будущем можно использовать для "ЮБИЛЕЙНОГО ПРЕДЛОЖЕНИЯ"
|
||||
# и фан-календаря
|
||||
max_length=10,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
default='XXXX-XX-XX',
|
||||
verbose_name='Дата релиза (str)',
|
||||
help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только'
|
||||
@@ -680,6 +836,7 @@ class TbItem(models.Model):
|
||||
t_item_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
verbose_name='Дата релиза',
|
||||
help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.',
|
||||
)
|
||||
@@ -702,6 +859,42 @@ class TbItem(models.Model):
|
||||
def __str__(self):
|
||||
return f"Item {self.id:0>4}: {self.s_item}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Переопределяем save для управления синонимами релиза (альбома) или просто товара и создания связанной статьи.
|
||||
|
||||
При сохранении релиза/альбома (создание и обновление):
|
||||
1. Управляем синонимами альбома/релиза/товара:
|
||||
- Для новых альбомов/релиза/товара: добавляем в SYN_EN
|
||||
- При изменении s_item: добавляем как старый, так и новый s_item в SYN_EN
|
||||
- При редактировании: используем j_item_metadata из формы (приоритет админу)
|
||||
2. Если статья не привязана - создаём новую автоматически
|
||||
3. Генерируем технический заголовок и slug для статьи
|
||||
"""
|
||||
# ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ =====
|
||||
# Проверяем ДО работы с синонимами и метаданными!
|
||||
# Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер
|
||||
validate_and_raise_for_duplicates(self, 's_item', 'j_item_metadata')
|
||||
|
||||
# ===== УПРАВЛЕНИЕ СИНОНИМАМИ =====
|
||||
# Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей)
|
||||
update_synonyms_in_metadata(self, 's_item', 'j_item_metadata')
|
||||
|
||||
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
|
||||
# Используем универсальный хелпер для создания/поиска статьи
|
||||
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
|
||||
article = create_or_get_related_article(
|
||||
self,
|
||||
TbArticle.ArticleType.ITEM,
|
||||
's_item',
|
||||
'j_item_metadata',
|
||||
'k_item_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
|
||||
)
|
||||
self.k_item_to_article = article
|
||||
|
||||
# Вызываем оригинальный save родительского класса
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)'
|
||||
verbose_name_plural = 'Товары в каталоге'
|
||||
@@ -739,7 +932,7 @@ class TbLabel(models.Model):
|
||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||
verbose_name='Связанная статья',
|
||||
help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.'
|
||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.'
|
||||
)
|
||||
j_label_metadata = models.JSONField(
|
||||
@@ -747,7 +940,9 @@ class TbLabel(models.Model):
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Метаданные',
|
||||
help_text='JSON: страна лейбла, официальный сайт и т.д.',
|
||||
help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга.'
|
||||
' Пример: <tt>{"SYN_EN": ["Island", "Island Records", "Vertigo France"]}</tt>.'
|
||||
,
|
||||
)
|
||||
t_label_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
|
||||
t_label_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
|
||||
@@ -761,8 +956,8 @@ class TbLabel(models.Model):
|
||||
|
||||
При сохранении лейбла (создание и обновление):
|
||||
1. Управляем синонимами лейбла:
|
||||
- Для новых лейблов: добавляем текущий s_label в SYNONYM
|
||||
- При изменении s_label: добавляем как старый, так и новый s_label в SYNONYM
|
||||
- Для новых лейблов: добавляем текущий s_label в SYN_EN
|
||||
- При изменении s_label: добавляем как старый, так и новый s_label в SYN_EN
|
||||
- При редактировании: используем j_label_metadata из формы (приоритет админу)
|
||||
2. Если статья не привязана - создаём новую автоматически
|
||||
3. Генерируем технический заголовок и slug для статьи
|
||||
@@ -852,7 +1047,7 @@ class TbSeller(models.Model):
|
||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||
verbose_name='Связанная статья',
|
||||
help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).'
|
||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.'
|
||||
)
|
||||
l_seller_type = models.CharField(
|
||||
@@ -872,6 +1067,29 @@ class TbSeller(models.Model):
|
||||
def __str__(self):
|
||||
return f"seller: {self.id:0>2}: {self.s_seller}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Переопределяем save для привязки связанной статьи о продавце.
|
||||
|
||||
0. ВАЖНО: Продавцам не нужны синонимы (хотя технически возможно), потому синонимы не проверяем и не обновляем
|
||||
1. Если статья не привязана - создаём новую статью исполнителя автоматически
|
||||
2. Генерируем технический заголовок и slug для статьи
|
||||
"""
|
||||
# ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ =====
|
||||
# Используем универсальный хелпер для создания/поиска статьи
|
||||
# Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал
|
||||
article = create_or_get_related_article(
|
||||
self,
|
||||
TbArticle.ArticleType.SELLER,
|
||||
's_seller',
|
||||
'j_seller_metadata',
|
||||
'k_seller_to_article' # ← Явно передаем имя FK поля (избегаем "магии")
|
||||
)
|
||||
self.k_seller_to_article = article
|
||||
|
||||
# Вызываем оригинальный save родительского класса
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Продавец'
|
||||
verbose_name_plural = 'Продавцы'
|
||||
@@ -885,6 +1103,13 @@ class TbOffer(models.Model):
|
||||
"""
|
||||
Конкретное предложение от продавца.
|
||||
Один и тот же релиз может быть несколько раз в системе от разных продавцов.
|
||||
|
||||
СВЯЗЬ С КАРТИНКАМИ:
|
||||
Картинки к офферу управляются через TbImageMetadata.offer (M2M):
|
||||
- offer.image_metadata.all() — все картинки этого офера (упорядочены по i_img_sort)
|
||||
- TbImageMetadata(offer=self, image=<картинка>, i_img_sort=0, j_img_metadata={...})
|
||||
|
||||
В админке это выглядит как M2M с дополнительными полями (сортировка, метаданные).
|
||||
"""
|
||||
class Condition(models.TextChoices):
|
||||
S = 's', 'Still Sealed (новое, запечатано)'
|
||||
@@ -973,16 +1198,19 @@ class TbOffer(models.Model):
|
||||
help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные '
|
||||
'продавца: offer.k_offer_to_source.k_source_to_seller',
|
||||
)
|
||||
k_offer_to_image = models.ManyToManyField(
|
||||
# Изображения товара (одна картинка может быть у многих офферов, а оффер иметь много картинок)
|
||||
# M2M связь для удобства в админке (filter_horizontal)
|
||||
# Порядок картинок определяется полем i_img_sort в TbImage
|
||||
TbImage,
|
||||
# Изображения (M2M к filer.Image через TbImageMetadata промежуточную таблицу)
|
||||
m_offer_to_image = models.ManyToManyField(
|
||||
'filer.Image',
|
||||
through='TbImageMetadata',
|
||||
related_name='m_image_to_offer',
|
||||
blank=True,
|
||||
related_name='image_to_offer',
|
||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
||||
verbose_name='Изображения',
|
||||
help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.',
|
||||
verbose_name='Изображения товара',
|
||||
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.',
|
||||
)
|
||||
# Характеристики
|
||||
s_offer_catalog_num = models.TextField(
|
||||
@@ -991,12 +1219,19 @@ class TbOffer(models.Model):
|
||||
verbose_name='Каталожный номер / Barcode',
|
||||
help_text='Например: "SD 16023" или "5099923452355"',
|
||||
)
|
||||
b_offer_is_preorder = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
verbose_name='Предзаказ',
|
||||
help_text='Если товар доступен только для предзаказа',
|
||||
)
|
||||
d_offer_date_release = models.DateField(
|
||||
blank=True,
|
||||
blank=False,
|
||||
null=True,
|
||||
default=None,
|
||||
db_index=True,
|
||||
verbose_name='Дата релиза',
|
||||
help_text='Дата релиза, если она известна (например, дата выпуска переиздания)',
|
||||
help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна',
|
||||
)
|
||||
i_offer_discogs_id = models.IntegerField(
|
||||
blank=True,
|
||||
@@ -1101,6 +1336,7 @@ class TbOffer(models.Model):
|
||||
# M2M не поддерживают участие в constraints. Уникальность на уровне БД не требуется.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class TbSource(models.Model):
|
||||
"""
|
||||
Источник данных, из которого был импортирован оффер.
|
||||
@@ -1249,4 +1485,3 @@ class TbOfferHistory(models.Model):
|
||||
# Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены)
|
||||
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
|
||||
]
|
||||
|
||||
|
||||
+37
-1075
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -317,7 +317,19 @@ LOGGING = {
|
||||
SLUG_MAX_LENGTH = 60
|
||||
|
||||
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
|
||||
KEY_SYNONYM = 'SYNONYM'
|
||||
KEY_SYNONYM_EN = 'SYN_EN' # Ключ для хранения синонимов на английском языке (для поиска по синонимам) и ДЛЯ ПАРСЕРОВ
|
||||
KEY_SYNONYM_RU = 'SYN_RU' # Ключ для хранения синонимов на русском языке (для представления на фронтенд)
|
||||
|
||||
# Ключи и значения для метаданных картинок
|
||||
KEY_IMAGE_TYPE = 'IMG_IS' # Ключ для хранения типа картинки
|
||||
VALUE_IMAGE_ABSTRACT = 'abstract' # Абстрактная картинка (лого лейбла, фото исполнителя, типовая обложка альбома...)
|
||||
VALUE_IMAGE_REAL = 'real' # Реальная картинка (актуально, для фото товаров в TbOffer)
|
||||
KEY_IMAGE_FROM = 'IMG_FROM' # Ключ, о том, как получено изображение
|
||||
VALUE_IMAGE_FROM_USER = '_adm' # Картинка загружена пользователем через админку
|
||||
VALUE_IMAGE_FROM_PARSE = 'parse' # Картинка получена через парсер (например, с сайта Discogs, Bandcamp и т.д.)
|
||||
KEY_IMAGE_URL = 'IMG_URL' # Ключ для хранения URL оригинальной картинки (обычно это должен прописать парсер)
|
||||
KEY_IMAGE_NOTE = 'IMG_NOTE' # Ключ для хранения заметки о картинке
|
||||
|
||||
|
||||
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
|
||||
VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
}
|
||||
|
||||
/* --- Новый класс для минимальной высоты --- */
|
||||
.codemirror-min-height-2 .cm-editor {
|
||||
min-height: 3.5em; /* 5 строк */
|
||||
}
|
||||
.codemirror-min-height-5 .cm-editor {
|
||||
min-height: 8em; /* 5 строк */
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* 4. Визуально показывает пользователю статус обхода валидации цветом кнопок
|
||||
*
|
||||
* Универсальное решение: работает для любых форм в админке, не только для лейблов.
|
||||
*
|
||||
* ВАЖНО: Этот скрипт работает независимо от порядка загрузки и инициализации CodeMirror.
|
||||
* Используется стратегия проверки readyState и MutationObserver для динамического отслеживания
|
||||
* появления новых элементов, включая CodeMirror div.codemirror.
|
||||
*/
|
||||
|
||||
// Функция для добавления GET параметра к form action кнопки
|
||||
@@ -42,7 +46,8 @@ function markSubmitButtonsToIgnoreValidation() {
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// ===== ОСНОВНАЯ ФУНКЦИЯ ИНИЦИАЛИЗАЦИИ ВОТЧЕРА =====
|
||||
function initFormWatcher() {
|
||||
// Находим все submit-кнопки администратора
|
||||
let submitButtons = document.querySelectorAll('input[type=submit]');
|
||||
|
||||
@@ -80,39 +85,51 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
// Отслеживаем изменения всех типов полей в форме
|
||||
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
|
||||
// ВАЖНО: Используем MutationObserver для динамического отслеживания появления CodeMirror элементов
|
||||
let watchedInputs = new WeakSet();
|
||||
|
||||
// Функция которая срабатывает при любом изменении
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
form.setAttribute('action', originalAction);
|
||||
|
||||
// Скрываем сообщения об ошибках валидации
|
||||
let errorNotes = document.querySelectorAll('.errornote, .errorlist');
|
||||
errorNotes.forEach(function (errorElement) {
|
||||
errorElement.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
formInputs.forEach(function (input) {
|
||||
// Слушаем оба события: 'change' для обычных input/select
|
||||
// и 'input' для CodeMirror и других редакторов
|
||||
input.addEventListener('change', handleChange);
|
||||
input.addEventListener('input', handleChange);
|
||||
});
|
||||
// Первый раз присоединяем слушателей к уже существующим элементам
|
||||
attachWatchersToInputs();
|
||||
|
||||
// Мониторим изменение класса force-ignore-validation на кнопках
|
||||
// Используем MutationObserver для отслеживания добавления/удаления класса
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
const classObserver = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
let btn = mutation.target;
|
||||
@@ -130,8 +147,67 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// Настраиваем observer для отслеживания изменения класса
|
||||
// Настраиваем observer для отслеживания изменения класса на кнопках
|
||||
submitButtons.forEach(function (btn) {
|
||||
observer.observe(btn, {attributes: true, attributeFilter: ['class']});
|
||||
classObserver.observe(btn, {attributes: true, attributeFilter: ['class']});
|
||||
});
|
||||
});
|
||||
|
||||
// ===== ДИНАМИЧЕСКОЕ ОТСЛЕЖИВАНИЕ ПОЯВЛЕНИЯ НОВЫХ ЭЛЕМЕНТОВ (особенно CodeMirror) =====
|
||||
// Используем MutationObserver для отслеживания появления новых элементов формы
|
||||
// Это нужно на случай если CodeMirror инициализируется ПОСЛЕ загрузки скрипта
|
||||
const formObserver = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
// Проверяем появились ли новые узлы (добавлены новые элементы)
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||
// Проверяем есть ли среди добавленных элементов CodeMirror div или input/textarea
|
||||
let hasNewFormElements = false;
|
||||
mutation.addedNodes.forEach(function (node) {
|
||||
if (node.nodeType === 1) { // Node.ELEMENT_NODE
|
||||
if (node.classList && (node.classList.contains('codemirror') ||
|
||||
node.tagName === 'INPUT' ||
|
||||
node.tagName === 'TEXTAREA' ||
|
||||
node.tagName === 'SELECT')) {
|
||||
hasNewFormElements = true;
|
||||
}
|
||||
// Проверяем в потомках
|
||||
if (node.querySelector && (node.querySelector('.codemirror') ||
|
||||
node.querySelector('input:not([type=submit])') ||
|
||||
node.querySelector('textarea') ||
|
||||
node.querySelector('select'))) {
|
||||
hasNewFormElements = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Если нашли новые элементы формы, повторно присоединяем слушателей
|
||||
if (hasNewFormElements) {
|
||||
attachWatchersToInputs();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Настраиваем observer для отслеживания изменений в форме (появления новых элементов)
|
||||
formObserver.observe(form, {childList: true, subtree: true});
|
||||
}
|
||||
|
||||
// ===== ИНИЦИАЛИЗАЦИЯ: Работает в любых условиях (до или после DOMContentLoaded) =====
|
||||
// Стратегия:
|
||||
// 1. Если document.readyState === 'loading', ждем DOMContentLoaded
|
||||
// 2. Если document.readyState === 'interactive' или 'complete', запускаем сразу
|
||||
// 3. Используем requestIdleCallback если доступен, иначе setTimeout
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// DOM еще загружается, ждем события
|
||||
document.addEventListener('DOMContentLoaded', initFormWatcher);
|
||||
} else {
|
||||
// DOM уже загружен (скрипт загрузился после DOMContentLoaded)
|
||||
// Запускаем инициализацию асинхронно чтобы все элементы успели загрузиться
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
// Используем requestIdleCallback для загрузки в свободное время браузера
|
||||
requestIdleCallback(initFormWatcher, {timeout: 2000});
|
||||
} else {
|
||||
// Fallback: используем setTimeout с минимальной задержкой
|
||||
setTimeout(initFormWatcher, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user