Compare commits
25 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 |
@@ -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?** Или еще вопросы/уточнения?
|
||||||
+722
-282
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 datetime
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('filer', '0018_alter_file_options'),
|
('filer', '0019_alter_clipboard_id_alter_clipboarditem_id_and_more'),
|
||||||
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('s_article_title', models.CharField(default='', help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road" или "Bio: The Beatles".', max_length=255, unique=True, verbose_name='Технический заголовок')),
|
('s_article_title', models.CharField(default='', help_text='Технический заголовок статьи для внутреннего использования, например: "Album: Abbey Road" или "Bio: The Beatles".', max_length=255, unique=True, verbose_name='Технический заголовок')),
|
||||||
('l_article_type', models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')),
|
('l_article_type', models.CharField(blank=True, choices=[('artist', 'Artis: артист, группа или бренд'), ('style', 'Slyle: музыкальный стиль'), ('item', 'Item: Альбом, релиз или товар (кассета, hifi, аксессуар)'), ('label', 'Label: Лейбл, издатель или компания'), ('offer', 'Offer: конкретное предложение от продавца'), ('seller', 'Seller: продавец или магазин'), ('blog', 'Новость или блог'), ('action', 'Спецпредложение, акция, распродажа и т.д.'), ('to_main', 'Текст/Блок для главной страницы'), ('adv', 'Реклама или баннер'), ('???', 'Другое')], db_index=True, default='???', max_length=7, verbose_name='Тип статьи')),
|
||||||
('b_article_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубликовано')),
|
('b_article_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубликовано')),
|
||||||
('t_article_started', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата начала публикации')),
|
('t_article_started', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата начала публикации')),
|
||||||
('t_article_ended', models.DateTimeField(blank=True, db_index=True, default=None, help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации. Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена или снята с публикации через `b_article_published`)', null=True, verbose_name='Дата окончания публикации')),
|
('t_article_ended', models.DateTimeField(blank=True, db_index=True, default=None, help_text='Если указано, статья будет отображаться только между датой начала и датой окончания публикации. Если не указано, статья будет отображаться всегда (или до тех пор, пока не будет удалена или снята с публикации через `b_article_published`)', null=True, verbose_name='Дата окончания публикации')),
|
||||||
@@ -45,41 +45,12 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'),
|
'ordering': ('-t_article_updated', '-t_article_created', 's_article_title'),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='TbFormat',
|
|
||||||
fields=[
|
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
|
||||||
('s_format', models.CharField(db_index=True, help_text='Название формата носителя, например: "LP", "CD", "Blu-ray", "Compact Cassette", "MiniDisc", "Hi-Fi", "Accessory" и т.д.', max_length=16, unique=True, verbose_name='Формат носителя')),
|
|
||||||
('s_format_slug', models.SlugField(max_length=16, unique=True, verbose_name='Слаг')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Формат носителя',
|
|
||||||
'verbose_name_plural': 'Форматы носителей',
|
|
||||||
'ordering': ('s_format',),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TbMusicStyle',
|
|
||||||
fields=[
|
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
|
||||||
('s_style_name', models.CharField(db_index=True, help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"', max_length=100, unique=True, verbose_name='Стиль (канонический)')),
|
|
||||||
('s_style_slug', models.SlugField(editable=False, help_text='Автоматически генерируется из названия. Используется в URL и API.', unique=True, verbose_name='Слаг (уникальный идентификатор)')),
|
|
||||||
('j_style_synonyms', models.JSONField(blank=True, default=list, help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]', verbose_name='Синонимы из источников')),
|
|
||||||
('t_style_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
|
||||||
('t_style_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Музыкальный стиль',
|
|
||||||
'verbose_name_plural': 'Музыкальные стили',
|
|
||||||
'ordering': ('s_style_name',),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TbArtist',
|
name='TbArtist',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||||
('s_artist', models.CharField(help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или"David Bowie".', max_length=128, unique=True, verbose_name='Исполнитель')),
|
('s_artist', models.CharField(help_text='Техническое название исполнителя для внутреннего использования, например: "The Beatles" или"David Bowie".', max_length=128, unique=True, verbose_name='Исполнитель')),
|
||||||
('j_artist_metadata', models.JSONField(blank=True, default=list, help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles", "Beatles, The"]', null=True, verbose_name='Метаданные JSON')),
|
('j_artist_metadata', models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYNONYM": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON')),
|
||||||
('t_artist_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('t_artist_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('t_artist_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
('t_artist_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
('k_artist_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
('k_artist_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||||
@@ -96,13 +67,12 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('l_img_source', models.CharField(choices=[('parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'), ('manual', 'Ручная загрузка пользователем'), ('vendor', 'От продавца'), ('other', 'Другое')], default='manual', help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например, Discogs), предоставлен продавцом и т.д.', max_length=10, verbose_name='Источник')),
|
('l_img_source', models.CharField(choices=[('parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'), ('manual', 'Ручная загрузка пользователем'), ('vendor', 'От продавца'), ('other', 'Другое')], default='manual', help_text='Как был получен этот снимок: загружен вручную, получен парсером из внешнего источника (например, Discogs), предоставлен продавцом и т.д.', max_length=10, verbose_name='Источник')),
|
||||||
('l_img_reality', models.CharField(choices=[('real', 'Реальная фотография товара'), ('abstract', 'Абстрактное (из внешнего источника)')], default='abstract', help_text='Реальная фотография товара или картинка из внешнего источника?', max_length=10, verbose_name='Тип снимка')),
|
('l_img_reality', models.CharField(choices=[('real', 'Реальная фотография товара'), ('abstract', 'Абстрактное (из внешнего источника)')], default='abstract', help_text='Реальная фотография товара или картинка из внешнего источника?', max_length=10, verbose_name='Тип снимка')),
|
||||||
('s_img_src_url', models.URLField(blank=True, help_text='Если изображение взято из внешнего источника (например, Discogs)', null=True, verbose_name='URL источника')),
|
('s_img_src_url', models.URLField(blank=True, help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL')),
|
||||||
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Cортировка')),
|
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка')),
|
||||||
('f_img_confidence_score', models.FloatField(blank=True, default=None, help_text='0.0 - 1.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Уверенность (для автоматических данных)')),
|
('f_img_confidence_score', models.FloatField(blank=True, default=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность')),
|
||||||
('s_img_copyright', models.CharField(blank=True, default='', help_text='Например: "© 2024 User" или "CC-BY"', max_length=255, verbose_name='Авторские права / Лицензия')),
|
|
||||||
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||||
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
('image', filer.fields.image.FilerImageField(blank=True, help_text='Файл изображения, загруженный через django_filer.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
('image', filer.fields.image.FilerImageField(help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Изображение',
|
'verbose_name': 'Изображение',
|
||||||
@@ -115,32 +85,12 @@ class Migration(migrations.Migration):
|
|||||||
name='k_article_to_image',
|
name='k_article_to_image',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to='frontend.tbimage', verbose_name='Изображение для статьи'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to='frontend.tbimage', verbose_name='Изображение для статьи'),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='TbItem',
|
|
||||||
fields=[
|
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('s_item', models.CharField(help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".', max_length=128, unique=True, verbose_name='Товар')),
|
|
||||||
('s_item_date', models.CharField(blank=True, default='XXXX-XX-XX', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию. Срабатывает только если t_release_date не указано.', max_length=10, null=True, verbose_name='Дата релиза (str)')),
|
|
||||||
('t_item_date', models.DateField(blank=True, help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', null=True, verbose_name='Дата релиза')),
|
|
||||||
('i_discogs_master_id', models.IntegerField(blank=True, default=None, help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: <tt>306323</tt>', null=True, verbose_name='ID на мастер-релиз Discogs')),
|
|
||||||
('j_item_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', null=True, verbose_name='Дополнительные данные')),
|
|
||||||
('t_item_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
|
||||||
('t_item_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
|
||||||
('k_item_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
|
||||||
('k_item_to_artist', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько для коллабораций', related_name='artist_to_item', to='frontend.tbartist', verbose_name='Исполнители')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Товар в каталоге (релиз, носитель, аксессуар)',
|
|
||||||
'verbose_name_plural': 'Товары в каталоге',
|
|
||||||
'ordering': ('s_item',),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TbLabel',
|
name='TbLabel',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||||
('s_label', models.CharField(help_text='Техническое название лейбла. Например: "Sony Records" или "Мелодия"', max_length=128, unique=True, verbose_name='Лейбл')),
|
('s_label', models.CharField(help_text='Название лейбла. Например: "Sony Records" или "Мелодия"... Представление на самом сайте, с версткой, будет определиться через статью, связанную с лейблом (TbArticle).', max_length=128, unique=True, verbose_name='Лейбл')),
|
||||||
('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д.', null=True, verbose_name='Метаданные')),
|
('j_label_metadata', models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <tt>{"SYNONYM": ["Island", "Island Records", "Vertigo France"]}</tt>.', null=True, verbose_name='Метаданные')),
|
||||||
('t_label_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('t_label_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('t_label_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
('t_label_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
('k_label_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
('k_label_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||||
@@ -151,18 +101,52 @@ class Migration(migrations.Migration):
|
|||||||
'ordering': ('s_label',),
|
'ordering': ('s_label',),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.CreateModel(
|
||||||
model_name='tbarticle',
|
name='TbMusicStyle',
|
||||||
name='k_article_to_styles',
|
fields=[
|
||||||
field=models.ManyToManyField(blank=True, db_index=True, help_text='Стили этой статьи/артиста/релиза (Rock, Jazz, Classical, ...)', related_name='style_to_article', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили'),
|
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||||
|
('s_style_name', models.CharField(db_index=True, help_text='Основное название стиля. Например: "Rock", "Jazz", "Classical"', max_length=100, unique=True, verbose_name='Стиль (канонический)')),
|
||||||
|
('j_style_metadata', models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYNONYM": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.', verbose_name='Метаданные')),
|
||||||
|
('t_style_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('t_style_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
|
('k_style_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья о музыкальном стиле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_style', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Музыкальный стиль',
|
||||||
|
'verbose_name_plural': 'Музыкальные стили',
|
||||||
|
'ordering': ('s_style_name',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TbItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('s_item', models.CharField(help_text='Техническое название товара (альбома, релиза, аксессуара) для внутреннего использования,например: "Abbey Road (LP)" или "TDK CDing I (кассета для записи)".', max_length=128, unique=True, verbose_name='Товар')),
|
||||||
|
('s_item_date', models.CharField(blank=True, db_index=True, default='XXXX-XX-XX', help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только год, или XXXX-XX-XX (если дата релиза неизвестна). Менее приоритетное поле для отображения даты релиза, чем t_release_date, так как может содержать неполную дату и/или текстовую информацию. Срабатывает только если t_release_date не указано.', max_length=10, null=True, verbose_name='Дата релиза (str)')),
|
||||||
|
('t_item_date', models.DateField(blank=True, db_index=True, help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.', null=True, verbose_name='Дата релиза')),
|
||||||
|
('i_discogs_master_id', models.IntegerField(blank=True, default=None, help_text='Уникальный идентификатор мастер-релиза на Discogs, если он там есть. Например: <tt>306323</tt>', null=True, verbose_name='ID на мастер-релиз Discogs')),
|
||||||
|
('j_item_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные и метаданные релиза (страна, жанр, количество треков и т.д.) или товавра в виде JSON-словаря. Сюда же включены варианты написания релиза в источниках', null=True, verbose_name='Дополнительные данные')),
|
||||||
|
('t_item_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('t_item_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
|
('k_item_to_article', models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||||
|
('k_item_to_artist', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько для коллабораций', related_name='artist_to_item', to='frontend.tbartist', verbose_name='Исполнители')),
|
||||||
|
('k_item_to_style', models.ManyToManyField(blank=True, db_index=True, help_text='Один или несколько стилей, характеризующих альбом/товар. Например: Rock, Progressive Rock.', related_name='style_to_item', to='frontend.tbmusicstyle', verbose_name='Музыкальные стили')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Товар в каталоге (релиз, носитель, аксессуар)',
|
||||||
|
'verbose_name_plural': 'Товары в каталоге',
|
||||||
|
'ordering': ('s_item',),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TbOffer',
|
name='TbOffer',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('s_offer', models.CharField(db_index=True, help_text='Техническое название оффера для внутреннего использования, например: "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"', max_length=128, verbose_name='Название оффера')),
|
('s_offer', models.CharField(db_index=True, help_text='Техническое название оффера для внутреннего использования, например: "Abbey Road (LP) AnTrop NM/NM (МЗГ)" или "TDK CDing I 60 (б/у) VG/VG (Janan, 198x синяя-градиент)"', max_length=128, verbose_name='Название оффера')),
|
||||||
|
('l_offer_to_format', models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"formats": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат')),
|
||||||
('s_offer_catalog_num', models.TextField(blank=True, default='', help_text='Например: "SD 16023" или "5099923452355"', verbose_name='Каталожный номер / Barcode')),
|
('s_offer_catalog_num', models.TextField(blank=True, default='', help_text='Например: "SD 16023" или "5099923452355"', verbose_name='Каталожный номер / Barcode')),
|
||||||
('d_offer_date_release', models.DateField(blank=True, default=None, help_text='Дата релиза, если она известна (например, дата выпуска переиздания)', null=True, verbose_name='Дата релиза')),
|
('b_offer_is_preorder', models.BooleanField(db_index=True, default=False, help_text='Если товар доступен только для предзаказа', verbose_name='Предзаказ')),
|
||||||
|
('d_offer_date_release', models.DateField(db_index=True, default=None, help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', null=True, verbose_name='Дата релиза')),
|
||||||
('i_offer_discogs_id', models.IntegerField(blank=True, default=0, help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>', verbose_name='ID на релиз Discogs')),
|
('i_offer_discogs_id', models.IntegerField(blank=True, default=0, help_text='Уникальный идентификатор релиза на Discogs, если он там есть. Например: <tt>306323</tt>', verbose_name='ID на релиз Discogs')),
|
||||||
('l_offer_condition_media', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние носителя')),
|
('l_offer_condition_media', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние носителя (пластинки, CD и т.п.) по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние носителя')),
|
||||||
('l_offer_condition_sleeve', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние обложки')),
|
('l_offer_condition_sleeve', models.CharField(choices=[('s', 'Still Sealed (новое, запечатано)'), ('m', 'Mint (новое, распакованное)'), ('nm', 'Near Mint (почти новое)'), ('vg', 'Very Good (очень хорошее)'), ('g', 'Good (хорошее)'), ('f', 'Fair (удовлетворительное)'), ('p', 'Poor (плохое)'), ('??', 'Other')], default='s', help_text='Состояние обложки по шкале от "Still Sealed" (запечатано) до "Poor" (плохое).', max_length=2, verbose_name='Состояние обложки')),
|
||||||
@@ -176,7 +160,6 @@ class Migration(migrations.Migration):
|
|||||||
('t_offer_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('t_offer_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('t_offer_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
('t_offer_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
('k_offer_to_article', models.ForeignKey(blank=True, default=None, help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>МОЖНО НЕ УКАЗЫВАТЬ</b> т.к. URL оффера (для корзины) формируется через id или хеш.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_offer', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
('k_offer_to_article', models.ForeignKey(blank=True, default=None, help_text='Связанная статья об оффере (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>МОЖНО НЕ УКАЗЫВАТЬ</b> т.к. URL оффера (для корзины) формируется через id или хеш.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_offer', to='frontend.tbarticle', verbose_name='Связанная статья')),
|
||||||
('k_offer_to_format', models.ManyToManyField(blank=True, db_index=True, help_text='Форматы носителей (пластинка, CD, кассета и т.п.). Можно выбрать несколько.', related_name='format_to_offer', to='frontend.tbformat', verbose_name='Форматы')),
|
|
||||||
('k_offer_to_image', models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.', related_name='image_to_offer', to='frontend.tbimage', verbose_name='Изображения')),
|
('k_offer_to_image', models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.', related_name='image_to_offer', to='frontend.tbimage', verbose_name='Изображения')),
|
||||||
('k_offer_to_item', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='item_to_offer', to='frontend.tbitem', verbose_name='Релиз (товар)')),
|
('k_offer_to_item', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='item_to_offer', to='frontend.tbitem', verbose_name='Релиз (товар)')),
|
||||||
('k_offer_to_label', models.ForeignKey(default=None, help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл')),
|
('k_offer_to_label', models.ForeignKey(default=None, help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: <tt>Atlantic</tt> или <tt>Мелодия</tt>', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл')),
|
||||||
@@ -208,6 +191,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||||
('s_seller', models.CharField(help_text='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать с названием продавца, если лейбл сам реализует свои издания через сайт.', max_length=128, unique=True, verbose_name='Название продавца')),
|
('s_seller', models.CharField(help_text='Техническое название продавца или магазина. Например: <tt>Клюква Рекодс</tt>. Может совпадать с названием продавца, если лейбл сам реализует свои издания через сайт.', max_length=128, unique=True, verbose_name='Название продавца')),
|
||||||
|
('l_seller_currency', models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('amd', 'AMD: армянских драм'), ('try', 'TRY: турецкая лира'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника')),
|
||||||
('l_seller_type', models.CharField(choices=[('seller', 'Продавец'), ('label', 'Лейбл (издатель)'), ('diy', 'Самиздат группы'), ('crowd', 'Краудфандинг'), ('???', 'Другое')], default='seller', max_length=6, verbose_name='Тип продавца')),
|
('l_seller_type', models.CharField(choices=[('seller', 'Продавец'), ('label', 'Лейбл (издатель)'), ('diy', 'Самиздат группы'), ('crowd', 'Краудфандинг'), ('???', 'Другое')], default='seller', max_length=6, verbose_name='Тип продавца')),
|
||||||
('j_seller_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.', null=True, verbose_name='Дополнительные данные')),
|
('j_seller_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные о продавце в виде JSON-словаря. Телефон, email, адрес, ссылка на сайт и т.д.', null=True, verbose_name='Дополнительные данные')),
|
||||||
('t_seller_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('t_seller_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
@@ -224,9 +208,8 @@ class Migration(migrations.Migration):
|
|||||||
name='TbSource',
|
name='TbSource',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
('id', models.SmallAutoField(primary_key=True, serialize=False)),
|
||||||
('l_source_currency', models.CharField(choices=[('rub', 'RUB: российский рубль'), ('usd', 'USD: американский доллар'), ('eur', 'EUR: евро'), ('try', 'TRY: турецкая лира'), ('amd', 'AMD: армянский драм'), ('jpy', 'JPY: японская иена'), ('gbp', 'GBP: британский фунт'), ('cny', 'CNY: китайский юань'), ('byn', 'BYN: белорусский рубль'), ('ton', 'TON: криптовалюта TON'), ('??', 'Other')], default='rub', help_text='В какой валюте указаны цены в этом источнике. Все офферы из этого источника будут в этой валюте.', max_length=3, verbose_name='Валюта источника')),
|
|
||||||
('s_source_name', models.CharField(blank=True, default='', help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>', max_length=128, verbose_name='Название источника')),
|
('s_source_name', models.CharField(blank=True, default='', help_text='Название источника данных (для удобства), например: <tt>Предзаказ на RSD-2025 от Полуэкта.</tt>', max_length=128, verbose_name='Название источника')),
|
||||||
('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')),
|
('l_source_type', models.CharField(choices=[('excel', 'Excel-файл от продавца или издателя'), ('csv', 'CSV-файл от продавца или издателя'), ('url', 'URL страницы с данными (например, HTML-страница с каталогом товаров)'), ('??', 'Другое (включая ручной ввод)')], default='excel', help_text='Тип источника данных, например: <tt>Excel-файл от продавца или издателя</tt>, <tt>URL страницы с данными</tt> и т.д.', max_length=5, verbose_name='Тип источника')),
|
||||||
('t_source_data', models.DateField(blank=True, default=datetime.date.today, help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', verbose_name='Дата данных')),
|
('t_source_data', models.DateField(blank=True, default=datetime.date.today, help_text='Дата, к которой относятся данные в источнике. Например, если это исторический Excel-файл.', verbose_name='Дата данных')),
|
||||||
('s_source_url', models.TextField(blank=True, default='', help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.', max_length=255, verbose_name='URL источника')),
|
('s_source_url', models.TextField(blank=True, default='', help_text='URL страницы с данными, например, HTML-страница с каталогом товаров. Если данные в источнике представлены в виде файла, можно не указывать URL, а загрузить файл в поле выше.', max_length=255, verbose_name='URL источника')),
|
||||||
('j_source_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла, структуре HTML-страницы и т.п.) в виде JSON-словаря', verbose_name='Дополнительные данные')),
|
('j_source_metadata', models.JSONField(blank=True, default=dict, help_text='Дополнительные данные об источнике (внутреннем устройстве: вкладках и стоkбцах Excel-файла, структуре HTML-страницы и т.п.) в виде JSON-словаря', verbose_name='Дополнительные данные')),
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-07-10 07:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import filer.fields.image
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('frontend', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbarticle',
|
||||||
|
name='k_article_to_image',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.</br><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbartist',
|
||||||
|
name='j_artist_metadata',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.', null=True, verbose_name='Метаданные JSON'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbartist',
|
||||||
|
name='k_artist_to_article',
|
||||||
|
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_artist', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbitem',
|
||||||
|
name='k_item_to_article',
|
||||||
|
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_item', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tblabel',
|
||||||
|
name='j_label_metadata',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='JSON: страна лейбла, официальный сайт и т.д. Включая список синонимов/вариантов названия для матчинга. Пример: <tt>{"SYN_EN": ["Island", "Island Records", "Vertigo France"]}</tt>.', null=True, verbose_name='Метаданные'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tblabel',
|
||||||
|
name='k_label_to_article',
|
||||||
|
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи. Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_label', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbmusicstyle',
|
||||||
|
name='j_style_metadata',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга. Пример: <tt>{"SYN_EN": ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]}</tt>.', verbose_name='Метаданные'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tboffer',
|
||||||
|
name='k_offer_to_image',
|
||||||
|
field=models.ManyToManyField(blank=True, db_index=True, help_text='Картинки этого товара/предложения. Порядок определяется полем i_img_sort в TbImageMetadata. Доступ к метаданным: image.metadata.i_img_sort и image.metadata.j_img_metadata', related_name='offer_images', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображения'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tbseller',
|
||||||
|
name='k_seller_to_article',
|
||||||
|
field=models.OneToOneField(blank=True, default=None, help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи). Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br /><b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='article_to_seller', to='frontend.tbarticle', verbose_name='Связанная статья'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TbImageMetadata',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('i_img_sort', models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка')),
|
||||||
|
('j_img_metadata', models.JSONField(blank=True, default=dict, help_text='JSON с дополнительными данными: источник (парсер, админка), тип (реальное/абстрактное), достоверность, URL источника и т.д. Гибкое хранилище, позволяет добавлять новые поля без миграций БД.', null=True, verbose_name='Метаданные')),
|
||||||
|
('l_img_source', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Источник')),
|
||||||
|
('l_img_reality', models.CharField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', max_length=10, null=True, verbose_name='[DEPRECATED] Тип снимка')),
|
||||||
|
('s_img_src_url', models.URLField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] URL')),
|
||||||
|
('f_img_confidence_score', models.FloatField(blank=True, help_text='[DEPRECATED] Использовать j_img_metadata вместо этого поля.', null=True, verbose_name='[DEPRECATED] Достоверность')),
|
||||||
|
('t_img_created', models.DateTimeField(auto_now_add=True, verbose_name='[DEPRECATED] Дата добавления')),
|
||||||
|
('t_img_updated', models.DateTimeField(auto_now=True, verbose_name='[DEPRECATED] Дата обновления')),
|
||||||
|
('image', filer.fields.image.FilerImageField(help_text='Файл изображения из django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, related_name='metadata', to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Метаданные изображения',
|
||||||
|
'verbose_name_plural': 'Метаданные изображений',
|
||||||
|
'ordering': ('i_img_sort',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='TbImage',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Generated by Django 6.0.5 on 2026-06-12 10:39
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import filer.fields.image
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('frontend', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='tboffer',
|
|
||||||
name='k_offer_to_format',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='tbimage',
|
|
||||||
name='s_img_copyright',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='tboffer',
|
|
||||||
name='l_offer_to_format',
|
|
||||||
field=models.CharField(choices=[('lp', 'Vinyl Long-Play (12")'), ('ep', 'Vinyl Extended-Play (12", 10", 7")'), ('45', 'Vinyl 7" (45 rpm)'), ('cd', 'Compact Disc'), ('ld', 'LaserDisc'), ('md', 'MiniDisc Record'), ('ms', 'Used MiniDisc (для записи)'), ('cs', 'Cassette Record'), ('uc', 'Used Cassette (для записи)'), ('tp', 'Tape Reel Record'), ('ur', 'Used Tape Reel (для записи)'), ('??', 'Other')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: <tt>{"formats": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='tbimage',
|
|
||||||
name='f_img_confidence_score',
|
|
||||||
field=models.FloatField(blank=True, default=10.0, help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение', null=True, verbose_name='Достоверность'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='tbimage',
|
|
||||||
name='i_img_sort',
|
|
||||||
field=models.IntegerField(db_index=True, default=0, help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать для указания обложки (0), задника (1) и т.д.', verbose_name='Сортировка'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='tbimage',
|
|
||||||
name='image',
|
|
||||||
field=filer.fields.image.FilerImageField(default=1, help_text='Файл изображения, загруженный через django_filer.', on_delete=django.db.models.deletion.DO_NOTHING, to=settings.FILER_IMAGE_MODEL, verbose_name='Файл изображения'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='tbimage',
|
|
||||||
name='s_img_src_url',
|
|
||||||
field=models.URLField(blank=True, help_text='URL источника, если изображение взято (в том числе и парсером) из внешнего источника (например, Discogs)', null=True, verbose_name='URL'),
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name='TbFormat',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+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='Тип источника'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+325
-91
@@ -84,7 +84,7 @@
|
|||||||
# │ PK: id │ SmallAutoField
|
# │ PK: id │ SmallAutoField
|
||||||
# │ s_style_name │ Название (Rock, Jazz, Classical...)
|
# │ s_style_name │ Название (Rock, Jazz, Classical...)
|
||||||
# │ k_style_to_article│ 1:1 OneToOne FK → TbArticle (для SEO, слага, контента)
|
# │ 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_created │ Timestamp
|
||||||
# │ t_style_updated │ Timestamp
|
# │ t_style_updated │ Timestamp
|
||||||
# │ │ ⬆ Индексы: id (+), related_name→article_to_style
|
# │ │ ⬆ Индексы: id (+), related_name→article_to_style
|
||||||
@@ -141,6 +141,8 @@
|
|||||||
# │ k_offer_to_source │ FK → TbSource [indexed]
|
# │ k_offer_to_source │ FK → TbSource [indexed]
|
||||||
# │ k_offer_to_article │ FK → TbArticle (опционально)
|
# │ k_offer_to_article │ FK → TbArticle (опционально)
|
||||||
# │ k_offer_to_image │ M2M → TbImage (несколько фото)
|
# │ 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_media │ Состояние (s, m, nm, vg, g, f, p)
|
||||||
# │ l_offer_condition_sleeve │ Состояние (s, m, nm, vg, g, f, p)
|
# │ l_offer_condition_sleeve │ Состояние (s, m, nm, vg, g, f, p)
|
||||||
# │ f_offer_price │ Цена [indexed для сортировки]
|
# │ f_offer_price │ Цена [indexed для сортировки]
|
||||||
@@ -151,7 +153,7 @@
|
|||||||
# │ i_offer_favorites │ Счетчик в избранном
|
# │ i_offer_favorites │ Счетчик в избранном
|
||||||
# │ t_offer_created │ Timestamp
|
# │ t_offer_created │ Timestamp
|
||||||
# │ t_offer_updated │ 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)
|
# │ │ ⬆ Constraint UNIQUE: (item, source, format)
|
||||||
# └────────────────────┬───────────┘
|
# └────────────────────┬───────────┘
|
||||||
# │
|
# │
|
||||||
@@ -191,7 +193,8 @@
|
|||||||
# │ k_item_to_artist │ M2M → TbArtist (для коллабораций)
|
# │ k_item_to_artist │ M2M → TbArtist (для коллабораций)
|
||||||
# │ k_item_to_style │ M2M → TbMusicStyle (жанры альбома)
|
# │ k_item_to_style │ M2M → TbMusicStyle (жанры альбома)
|
||||||
# │ k_item_to_article │ 1:1 FK → TbArticle (content, SEO, slug)
|
# │ 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
|
# │ i_discogs_master_id │ ID мастер-релиза на Discogs
|
||||||
# │ t_item_created │ Timestamp
|
# │ t_item_created │ Timestamp
|
||||||
# │ t_item_updated │ Timestamp
|
# │ t_item_updated │ Timestamp
|
||||||
@@ -237,89 +240,99 @@
|
|||||||
# - M2M использует числовые FK (INT) вместо строк
|
# - M2M использует числовые FK (INT) вместо строк
|
||||||
# - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места
|
# - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from filer.fields.image import FilerImageField
|
from filer.fields.image import FilerImageField
|
||||||
from filer.fields.file import FilerFileField
|
from filer.fields.file import FilerFileField
|
||||||
from frontend.utils import make_slug, update_synonyms_in_metadata, create_or_get_related_article
|
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 frontend.utils_validators import validate_and_raise_for_duplicates
|
||||||
import datetime
|
from lpon_site.settings import (
|
||||||
import logging
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ИЗОБРАЖЕНИЯ
|
# МЕТАДАННЫЕ ИЗОБРАЖЕНИЙ
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
class TbImage(models.Model):
|
class TbImageMetadata(models.Model):
|
||||||
"""
|
"""
|
||||||
Изображение, связанное с релизом, оффером, исполнителем и т.д.
|
Метаданные к изображениям из django_filer.
|
||||||
"""
|
|
||||||
# Источник изображения
|
|
||||||
class ImageSource(models.TextChoices):
|
|
||||||
PARSER_UPLOAD = 'parser', 'Загружено парсером (из Discogs, Meshok или другого сайта)'
|
|
||||||
MANUAL_UPLOAD = 'manual', 'Ручная загрузка пользователем'
|
|
||||||
VENDOR = 'vendor', 'От продавца'
|
|
||||||
OTHER = 'other', 'Другое'
|
|
||||||
|
|
||||||
# Тип изображения (реальное или абстрактное)
|
Промежуточная таблица для M2M связи TbOffer ↔ filer.Image.
|
||||||
class ImageReality(models.TextChoices):
|
Хранит дополнительные поля:
|
||||||
REAL_PHOTO = 'real', 'Реальная фотография товара'
|
- i_img_sort: порядок сортировки изображений (0=обложка, 1=задник, 2=вкладка и т.д.)
|
||||||
ABSTRACT = 'abstract', 'Абстрактное (из внешнего источника)'
|
- 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(
|
image = FilerImageField(
|
||||||
# Файл через django_filer
|
# Связь в M2M с filer.Image (часть промежуточной таблицы)
|
||||||
|
# CASCADE: если удалится картинка → удалить метаданные (метаданные без картинки бессмысленны)
|
||||||
null=False,
|
null=False,
|
||||||
blank=False,
|
blank=False,
|
||||||
on_delete=models.DO_NOTHING,
|
on_delete=models.CASCADE,
|
||||||
|
related_name='m_offer', # Встречный доступ: filer_image.m_offer.all()
|
||||||
verbose_name='Файл изображения',
|
verbose_name='Файл изображения',
|
||||||
help_text='Файл изображения, загруженный через django_filer.',
|
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)',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
i_img_sort = models.IntegerField(
|
i_img_sort = models.IntegerField(
|
||||||
# Порядок (сортировка) вывода
|
# Порядок (сортировка) вывода изображений
|
||||||
default=0,
|
default=0,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name='Сортировка',
|
verbose_name='Сортировка',
|
||||||
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. Можно использовать'
|
help_text='Порядок отображения изображений. Чем меньше число, тем выше в списке. '
|
||||||
' для указания обложки (0), задника (1) и т.д.',
|
'Можно использовать для указания обложки (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,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
default=10.0,
|
related_name='m_image', # Встречный доступ: offer.m_image.all()
|
||||||
verbose_name='Достоверность',
|
verbose_name='Оффер',
|
||||||
help_text='Уверенность (для автоматических данных) 0.0 - 10.0, насколько уверены, что это правильное изображение',
|
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:
|
class Meta:
|
||||||
verbose_name = 'Изображение'
|
verbose_name = 'Метаданные изображения'
|
||||||
verbose_name_plural = 'Изображения'
|
verbose_name_plural = 'Метаданные изображений'
|
||||||
ordering = ('-t_img_created', 'i_img_sort',)
|
ordering = ('i_img_sort',)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -391,14 +404,20 @@ class TbArticle(models.Model):
|
|||||||
' Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано,'
|
' Может содержать HTML-разметку для типографирования (html-мнемоники и -теги). Если не указано,'
|
||||||
' будет отображаться без заголовка.'
|
' будет отображаться без заголовка.'
|
||||||
)
|
)
|
||||||
k_article_to_image = models.ForeignKey(
|
k_article_to_image = FilerImageField(
|
||||||
TbImage,
|
# Метаданные изображения (сортировка, источник, тип и т.д.) находятся в TbImageMetadata.
|
||||||
|
# Если нужно получить метаданные изображения, можно использовать `article.k_article_to_image.metadata`.
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name='image_to_article',
|
related_name='image_to_article',
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
db_index=True,
|
||||||
verbose_name='Изображение для статьи',
|
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(
|
s_article_teaser_html = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -423,7 +442,7 @@ class TbArticle(models.Model):
|
|||||||
verbose_name='Число просмотров',
|
verbose_name='Число просмотров',
|
||||||
)
|
)
|
||||||
i_article_favorites = models.IntegerField(
|
i_article_favorites = models.IntegerField(
|
||||||
# Счетчик добавлений в избранное (включая избранное артиста, итема/релиза/товара, лейбла и продавца)
|
# Счетчик добавлений в избранное (включая избранный артист, item/релиз/товар/лейбл/продавец)
|
||||||
default=0,
|
default=0,
|
||||||
db_index=True, # для сортировки "самые добавляемые в избранное"
|
db_index=True, # для сортировки "самые добавляемые в избранное"
|
||||||
verbose_name='Число в избранном',
|
verbose_name='Число в избранном',
|
||||||
@@ -481,6 +500,7 @@ class TbArticle(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Автоматически генерируем slug на основе заголовка статьи.
|
Автоматически генерируем slug на основе заголовка статьи.
|
||||||
|
Если привязана картинка и метаданных нет — создаем TbImageMetadata с тегами IMG_IS и IMG_FROM.
|
||||||
Вызывается при каждом сохранении записи (создание или обновление).
|
Вызывается при каждом сохранении записи (создание или обновление).
|
||||||
"""
|
"""
|
||||||
# Если slug не установлен (новая запись) — генерируем его из названия
|
# Если slug не установлен (новая запись) — генерируем его из названия
|
||||||
@@ -498,8 +518,72 @@ class TbArticle(models.Model):
|
|||||||
|
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
|
|
||||||
|
# Сохраняем статью в БД (нужно чтобы потом привязать метаданные картинки)
|
||||||
super().save(*args, **kwargs)
|
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:
|
class Meta:
|
||||||
verbose_name = 'Статья'
|
verbose_name = 'Статья'
|
||||||
verbose_name_plural = 'Статьи'
|
verbose_name_plural = 'Статьи'
|
||||||
@@ -549,12 +633,12 @@ class TbMusicStyle(models.Model):
|
|||||||
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.'
|
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL музыкального стиля.'
|
||||||
)
|
)
|
||||||
j_style_synonyms = models.JSONField(
|
j_style_metadata = models.JSONField(
|
||||||
default=list,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name='Синонимы из источников',
|
verbose_name='Метаданные',
|
||||||
help_text='Список вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||||||
' Пример: ["rock", "Rock Music", "Rock & Roll", "Hard Rock"]',
|
' Пример: <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_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания")
|
||||||
t_style_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления")
|
t_style_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления")
|
||||||
@@ -562,6 +646,42 @@ class TbMusicStyle(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.s_style_name
|
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:
|
class Meta:
|
||||||
verbose_name = 'Музыкальный стиль'
|
verbose_name = 'Музыкальный стиль'
|
||||||
verbose_name_plural = 'Музыкальные стили'
|
verbose_name_plural = 'Музыкальные стили'
|
||||||
@@ -593,16 +713,16 @@ class TbArtist(models.Model):
|
|||||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||||
verbose_name='Связанная статья',
|
verbose_name='Связанная статья',
|
||||||
help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же'
|
help_text='Связанная статья об исполнителе (Типографированные заголовок, тизер и текст статьи. Так же'
|
||||||
' через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
' через статью может быть получена <b>картинка<b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.'
|
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL артиста.'
|
||||||
)
|
)
|
||||||
j_artist_metadata = models.JSONField(
|
j_artist_metadata = models.JSONField(
|
||||||
default=list,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name='Метаданные JSON',
|
verbose_name='Метаданные JSON',
|
||||||
help_text='Включая варианты написания в источниках Список вариантов: ["The Beatles", "Beatles",'
|
help_text='В основном список синонимов/вариантов названия из Discogs, MusicBrainz и т.д. для матчинга.'
|
||||||
' "Beatles, The"]',
|
' Пример: <tt>{"SYN_EN": ["The Beatles", "Beatles", "Beatles, The"]}</tt>.',
|
||||||
)
|
)
|
||||||
t_artist_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
|
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="Дата обновления",
|
t_artist_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",
|
||||||
@@ -611,6 +731,42 @@ class TbArtist(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"artist {self.id:0>4}: {self.s_artist}"
|
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:
|
class Meta:
|
||||||
verbose_name = 'Исполнитель'
|
verbose_name = 'Исполнитель'
|
||||||
verbose_name_plural = 'Исполнители'
|
verbose_name_plural = 'Исполнители'
|
||||||
@@ -656,21 +812,20 @@ class TbItem(models.Model):
|
|||||||
TbArticle,
|
TbArticle,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name='article_to_item',
|
related_name='article_to_item',
|
||||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
db_index=True,
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
blank=True,
|
||||||
verbose_name='Связанная статья',
|
verbose_name='Связанная статья',
|
||||||
help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
|
help_text='Связанная статья об альбоме/релизе/товаре (Типографированные заголовок, тизер и текст статьи.'
|
||||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.'
|
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL альбома/релиза/товара.'
|
||||||
)
|
)
|
||||||
s_item_date = models.CharField(
|
s_item_date = models.CharField(
|
||||||
# Год выпуска (важно для коллекционеров). Так же в будущем можно использовать для "ЮБИЛЕЙНОГО ПРЕДЛОЖЕНИЯ"
|
|
||||||
# и фан-календаря
|
|
||||||
max_length=10,
|
max_length=10,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
db_index=True,
|
||||||
default='XXXX-XX-XX',
|
default='XXXX-XX-XX',
|
||||||
verbose_name='Дата релиза (str)',
|
verbose_name='Дата релиза (str)',
|
||||||
help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только'
|
help_text='Например: 1969-05-25, или 1969-05-XX (если день неизвестен), или 1969-XX-XX (если известен только'
|
||||||
@@ -681,6 +836,7 @@ class TbItem(models.Model):
|
|||||||
t_item_date = models.DateField(
|
t_item_date = models.DateField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
db_index=True,
|
||||||
verbose_name='Дата релиза',
|
verbose_name='Дата релиза',
|
||||||
help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.',
|
help_text='Полная дата если известна, например: 1969-09-26. Если точно известен.',
|
||||||
)
|
)
|
||||||
@@ -703,6 +859,42 @@ class TbItem(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Item {self.id:0>4}: {self.s_item}"
|
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:
|
class Meta:
|
||||||
verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)'
|
verbose_name = 'Товар в каталоге (релиз, носитель, аксессуар)'
|
||||||
verbose_name_plural = 'Товары в каталоге'
|
verbose_name_plural = 'Товары в каталоге'
|
||||||
@@ -740,7 +932,7 @@ class TbLabel(models.Model):
|
|||||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||||
verbose_name='Связанная статья',
|
verbose_name='Связанная статья',
|
||||||
help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.'
|
help_text='Связанная статья об лейбле (Типографированные заголовок, тизер и текст статьи.'
|
||||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.'
|
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL лейбла.'
|
||||||
)
|
)
|
||||||
j_label_metadata = models.JSONField(
|
j_label_metadata = models.JSONField(
|
||||||
@@ -748,7 +940,9 @@ class TbLabel(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name='Метаданные',
|
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_created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Дата создания",)
|
||||||
t_label_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
|
t_label_updated = models.DateTimeField(auto_now=True, editable=False, verbose_name="Дата обновления",)
|
||||||
@@ -762,8 +956,8 @@ class TbLabel(models.Model):
|
|||||||
|
|
||||||
При сохранении лейбла (создание и обновление):
|
При сохранении лейбла (создание и обновление):
|
||||||
1. Управляем синонимами лейбла:
|
1. Управляем синонимами лейбла:
|
||||||
- Для новых лейблов: добавляем текущий s_label в SYNONYM
|
- Для новых лейблов: добавляем текущий s_label в SYN_EN
|
||||||
- При изменении s_label: добавляем как старый, так и новый s_label в SYNONYM
|
- При изменении s_label: добавляем как старый, так и новый s_label в SYN_EN
|
||||||
- При редактировании: используем j_label_metadata из формы (приоритет админу)
|
- При редактировании: используем j_label_metadata из формы (приоритет админу)
|
||||||
2. Если статья не привязана - создаём новую автоматически
|
2. Если статья не привязана - создаём новую автоматически
|
||||||
3. Генерируем технический заголовок и slug для статьи
|
3. Генерируем технический заголовок и slug для статьи
|
||||||
@@ -853,7 +1047,7 @@ class TbSeller(models.Model):
|
|||||||
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
blank=True, # <-- Интерфейсное удобство. Связь будет сделана автоматически, и статья создана автоматически.
|
||||||
verbose_name='Связанная статья',
|
verbose_name='Связанная статья',
|
||||||
help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).'
|
help_text='Связанная статья о продавце (HTML-готовые заголовок, тизер и текст статьи).'
|
||||||
' Так же через статью может быть получена картинка, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
' Так же через статью может быть получена <b>картинка</b>, seo атрибуты, слаг (обязательно) и т.п.)<br />'
|
||||||
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.'
|
'<b>ОБЯЗАТЕЛЬНО УКАЗЫВАТЬ</b> т.к. через статью получаем слаг для URL продавца.'
|
||||||
)
|
)
|
||||||
l_seller_type = models.CharField(
|
l_seller_type = models.CharField(
|
||||||
@@ -873,6 +1067,29 @@ class TbSeller(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"seller: {self.id:0>2}: {self.s_seller}"
|
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:
|
class Meta:
|
||||||
verbose_name = 'Продавец'
|
verbose_name = 'Продавец'
|
||||||
verbose_name_plural = 'Продавцы'
|
verbose_name_plural = 'Продавцы'
|
||||||
@@ -886,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):
|
class Condition(models.TextChoices):
|
||||||
S = 's', 'Still Sealed (новое, запечатано)'
|
S = 's', 'Still Sealed (новое, запечатано)'
|
||||||
@@ -974,16 +1198,19 @@ class TbOffer(models.Model):
|
|||||||
help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные '
|
help_text='Обязательно - каждый оффер должен иметь источник. Через источник получаем данные '
|
||||||
'продавца: offer.k_offer_to_source.k_source_to_seller',
|
'продавца: offer.k_offer_to_source.k_source_to_seller',
|
||||||
)
|
)
|
||||||
k_offer_to_image = models.ManyToManyField(
|
# Изображения (M2M к filer.Image через TbImageMetadata промежуточную таблицу)
|
||||||
# Изображения товара (одна картинка может быть у многих офферов, а оффер иметь много картинок)
|
m_offer_to_image = models.ManyToManyField(
|
||||||
# M2M связь для удобства в админке (filter_horizontal)
|
'filer.Image',
|
||||||
# Порядок картинок определяется полем i_img_sort в TbImage
|
through='TbImageMetadata',
|
||||||
TbImage,
|
related_name='m_image_to_offer',
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='image_to_offer',
|
verbose_name='Изображения товара',
|
||||||
db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст.
|
help_text='Картинки этого предложения (обложка, задник, фото и т.д). '
|
||||||
verbose_name='Изображения',
|
'Управляются в админке TbImageMetadata с сортировкой (i_img_sort) и метаданными (j_img_metadata). '
|
||||||
help_text='Картинки этого товара. Порядок определяется полем i_img_sort в ка��тинке.',
|
'Доступ в коде: '
|
||||||
|
'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(
|
s_offer_catalog_num = models.TextField(
|
||||||
@@ -992,12 +1219,19 @@ class TbOffer(models.Model):
|
|||||||
verbose_name='Каталожный номер / Barcode',
|
verbose_name='Каталожный номер / Barcode',
|
||||||
help_text='Например: "SD 16023" или "5099923452355"',
|
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(
|
d_offer_date_release = models.DateField(
|
||||||
blank=True,
|
blank=False,
|
||||||
null=True,
|
null=True,
|
||||||
default=None,
|
default=None,
|
||||||
|
db_index=True,
|
||||||
verbose_name='Дата релиза',
|
verbose_name='Дата релиза',
|
||||||
help_text='Дата релиза, если она известна (например, дата выпуска переиздания)',
|
help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна',
|
||||||
)
|
)
|
||||||
i_offer_discogs_id = models.IntegerField(
|
i_offer_discogs_id = models.IntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -1102,6 +1336,7 @@ class TbOffer(models.Model):
|
|||||||
# M2M не поддерживают участие в constraints. Уникальность на уровне БД не требуется.
|
# M2M не поддерживают участие в constraints. Уникальность на уровне БД не требуется.
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
class TbSource(models.Model):
|
class TbSource(models.Model):
|
||||||
"""
|
"""
|
||||||
Источник данных, из которого был импортирован оффер.
|
Источник данных, из которого был импортирован оффер.
|
||||||
@@ -1250,4 +1485,3 @@ class TbOfferHistory(models.Model):
|
|||||||
# Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены)
|
# Составной индекс: найти историю оффера, отсортированную по времени (для хронологии изменений цены)
|
||||||
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
|
models.Index(fields=['k_history_to_offer', '-t_history_created'], name='idx_history_by_offer_date'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+25
-13
@@ -16,7 +16,7 @@ from django.utils.html import mark_safe
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from lpon_site.settings import (
|
from lpon_site.settings import (
|
||||||
SLUG_MAX_LENGTH, KEY_SYNONYM,
|
SLUG_MAX_LENGTH, KEY_SYNONYM_EN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -223,10 +223,10 @@ def update_synonyms_in_metadata(
|
|||||||
Универсальный хелпер для управления синонимами во всех моделях (TbLabel, TbArtist, TbMusicStyle и т.д.)
|
Универсальный хелпер для управления синонимами во всех моделях (TbLabel, TbArtist, TbMusicStyle и т.д.)
|
||||||
|
|
||||||
Логика:
|
Логика:
|
||||||
- При создании новой записи: добавляет текущее значение поля в SYNONYM
|
- При создании новой записи: добавляет текущее значение поля в SYN_EN
|
||||||
- При редактировании: если значение поля изменилось, добавляет ОБА (старое и новое) в SYNONYM
|
- При редактировании: если значение поля изменилось, добавляет ОБА (старое и новое) в SYN_EN
|
||||||
- Очищает дубликаты в списке синонимов, сохраняя порядок
|
- Очищает дубликаты в списке синонимов, сохраняя порядок
|
||||||
- Использует KEY_SYNONYM из settings как ключ в metadata словаре
|
- Использует KEY_SYN_EN из settings как ключ в metadata словаре
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance: Экземпляр модели (self из save методе). Обязателен!
|
instance: Экземпляр модели (self из save методе). Обязателен!
|
||||||
@@ -282,25 +282,25 @@ def update_synonyms_in_metadata(
|
|||||||
setattr(instance, metadata_field_name, metadata_dict)
|
setattr(instance, metadata_field_name, metadata_dict)
|
||||||
|
|
||||||
# Убеждаемся, что ключ 'SYNONYM' существует и это список
|
# Убеждаемся, что ключ 'SYNONYM' существует и это список
|
||||||
if KEY_SYNONYM not in metadata_dict or not isinstance(metadata_dict[KEY_SYNONYM], list):
|
if KEY_SYNONYM_EN not in metadata_dict or not isinstance(metadata_dict[KEY_SYNONYM_EN], list):
|
||||||
metadata_dict[KEY_SYNONYM] = []
|
metadata_dict[KEY_SYNONYM_EN] = []
|
||||||
|
|
||||||
# ===== ДОБАВЛЯЕМ СИНОНИМЫ =====
|
# ===== ДОБАВЛЯЕМ СИНОНИМЫ =====
|
||||||
# Добавляем синонимы при создании ИЛИ если значение поля изменилось
|
# Добавляем синонимы при создании ИЛИ если значение поля изменилось
|
||||||
if is_new or old_field_value != current_field_value:
|
if is_new or old_field_value != current_field_value:
|
||||||
# Если поле было обновлено и значение изменилось - добавляем старое значение
|
# Если поле было обновлено и значение изменилось - добавляем старое значение
|
||||||
if old_field_value and old_field_value not in metadata_dict[KEY_SYNONYM]:
|
if old_field_value and old_field_value not in metadata_dict[KEY_SYNONYM_EN]:
|
||||||
metadata_dict[KEY_SYNONYM].append(old_field_value)
|
metadata_dict[KEY_SYNONYM_EN].append(old_field_value)
|
||||||
|
|
||||||
# Добавляем текущее значение если его еще нет в синонимах
|
# Добавляем текущее значение если его еще нет в синонимах
|
||||||
if current_field_value not in metadata_dict[KEY_SYNONYM]:
|
if current_field_value not in metadata_dict[KEY_SYNONYM_EN]:
|
||||||
metadata_dict[KEY_SYNONYM].append(current_field_value)
|
metadata_dict[KEY_SYNONYM_EN].append(current_field_value)
|
||||||
|
|
||||||
# ===== ОЧИЩАЕМ ДУБЛИКАТЫ =====
|
# ===== ОЧИЩАЕМ ДУБЛИКАТЫ =====
|
||||||
# Удаляем дубликаты в списке синонимов, сохраняя порядок
|
# Удаляем дубликаты в списке синонимов, сохраняя порядок
|
||||||
# (может случиться если пользователь вручную редактировал метаданные)
|
# (может случиться если пользователь вручную редактировал метаданные)
|
||||||
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
|
if KEY_SYNONYM_EN in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM_EN], list):
|
||||||
metadata_dict[KEY_SYNONYM] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM]))
|
metadata_dict[KEY_SYNONYM_EN] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM_EN]))
|
||||||
|
|
||||||
# ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ =====
|
# ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ =====
|
||||||
# Для JSONField нужно явно сообщить что мы изменили содержимое
|
# Для JSONField нужно явно сообщить что мы изменили содержимое
|
||||||
@@ -383,7 +383,7 @@ def create_or_get_related_article(
|
|||||||
|
|
||||||
# Собираем синонимы из метаданных для SEO ключевых слов и исключаем текущее значение поля
|
# Собираем синонимы из метаданных для SEO ключевых слов и исключаем текущее значение поля
|
||||||
# из списка (оно будет добавлено первым для приоритета)
|
# из списка (оно будет добавлено первым для приоритета)
|
||||||
other_synonyms = [s for s in instance.__dict__.get(metadata_field_name, {}).get(KEY_SYNONYM, []) if str(s) != main_field_value]
|
other_synonyms = [s for s in instance.__dict__.get(metadata_field_name, {}).get(KEY_SYNONYM_EN, []) if str(s) != main_field_value]
|
||||||
|
|
||||||
# Собираем все синонимы с текущим значением первым
|
# Собираем все синонимы с текущим значением первым
|
||||||
all_synonyms = [main_field_value] + other_synonyms if other_synonyms else [main_field_value]
|
all_synonyms = [main_field_value] + other_synonyms if other_synonyms else [main_field_value]
|
||||||
@@ -404,4 +404,16 @@ def create_or_get_related_article(
|
|||||||
# Сохраняем статью
|
# Сохраняем статью
|
||||||
article.save()
|
article.save()
|
||||||
|
|
||||||
|
# TODO: Отправить уведомление в Redis о созданной статье
|
||||||
|
# Это нужно для парсера и автоматического добавления лейблов/артистов/стилей
|
||||||
|
# Уведомление должно содержать: article.pk, article.s_article_title_html, model_type, article_type
|
||||||
|
# Админ должен получить очередь задач "Созданные статьи требуют проверки/редактирования"
|
||||||
|
# Пример: redis.lpush('parser:created_articles', json.dumps({
|
||||||
|
# 'pk': article.pk,
|
||||||
|
# 'title': article.s_article_title_html,
|
||||||
|
# 'article_type': str(article.l_article_type),
|
||||||
|
# 'model': instance.__class__.__name__,
|
||||||
|
# 'created_at': datetime.now().isoformat()
|
||||||
|
# }))
|
||||||
|
|
||||||
return article
|
return article
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from django.http import HttpRequest
|
|||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from lpon_site.settings import (
|
from lpon_site.settings import (
|
||||||
KEY_SYNONYM,
|
KEY_SYNONYM_EN,
|
||||||
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
|
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
|
||||||
ValidateMatchType, MIN_SYNONYM_WORD_LENGTH
|
ValidateMatchType, MIN_SYNONYM_WORD_LENGTH
|
||||||
)
|
)
|
||||||
@@ -143,7 +143,7 @@ def validate_for_duplicates(
|
|||||||
})
|
})
|
||||||
return duplicates_found
|
return duplicates_found
|
||||||
|
|
||||||
# ПРОВЕРКА 2: SYNONYM MATCH (совпадение с синонимами в метаданных других записей)
|
# ПРОВЕРКА 2:SYN_ENMATCH (совпадение с синонимами в метаданных других записей)
|
||||||
# Ищем: есть ли текущее значение main_field в синонимах других записей?
|
# Ищем: есть ли текущее значение main_field в синонимах других записей?
|
||||||
# Например, если у записи A синонимы=['Sony Music', 'SME Records'],
|
# Например, если у записи A синонимы=['Sony Music', 'SME Records'],
|
||||||
# а мы добавляем запись B с основным полем 'Sony Music', это совпадение!
|
# а мы добавляем запись B с основным полем 'Sony Music', это совпадение!
|
||||||
@@ -159,7 +159,7 @@ def validate_for_duplicates(
|
|||||||
has_synonym=RawSQL(
|
has_synonym=RawSQL(
|
||||||
f"""
|
f"""
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}')
|
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM_EN}')
|
||||||
WHERE LOWER(json_each.value) = %s
|
WHERE LOWER(json_each.value) = %s
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
@@ -179,8 +179,8 @@ def validate_for_duplicates(
|
|||||||
# основное поле + все синонимы из метаданных
|
# основное поле + все синонимы из метаданных
|
||||||
# Этот список переиспользуется на этапах 3 и 4, избегая дублирования логики
|
# Этот список переиспользуется на этапах 3 и 4, избегая дублирования логики
|
||||||
raw_values_to_analyze = [main_field_value]
|
raw_values_to_analyze = [main_field_value]
|
||||||
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
|
if KEY_SYNONYM_EN in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM_EN], list):
|
||||||
raw_values_to_analyze.extend(metadata_dict[KEY_SYNONYM])
|
raw_values_to_analyze.extend(metadata_dict[KEY_SYNONYM_EN])
|
||||||
|
|
||||||
# ПРОВЕРКА 3: EXACT_SYNONYM_MATCH (точное совпадение синонимов текущей записи с синонимами других)
|
# ПРОВЕРКА 3: EXACT_SYNONYM_MATCH (точное совпадение синонимов текущей записи с синонимами других)
|
||||||
# Ищем: есть ли синонимы из текущей записи в синонимах других записей?
|
# Ищем: есть ли синонимы из текущей записи в синонимах других записей?
|
||||||
@@ -188,10 +188,10 @@ def validate_for_duplicates(
|
|||||||
# Например, если текущая запись имеет синонимы=['Polydor Records', 'Vertigo France'],
|
# Например, если текущая запись имеет синонимы=['Polydor Records', 'Vertigo France'],
|
||||||
# а запись A имеет синонимы=['Vertigo France', 'Swirl'], это совпадение!
|
# а запись A имеет синонимы=['Vertigo France', 'Swirl'], это совпадение!
|
||||||
# Пользователь подтверждает - удалим конфликтующие синонимы из других записей.
|
# Пользователь подтверждает - удалим конфликтующие синонимы из других записей.
|
||||||
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
|
if KEY_SYNONYM_EN in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM_EN], list):
|
||||||
# Собираем все синонимы текущей записи с нормализацией
|
# Собираем все синонимы текущей записи с нормализацией
|
||||||
current_synonyms = [
|
current_synonyms = [
|
||||||
normalize_string(syn) for syn in metadata_dict[KEY_SYNONYM]
|
normalize_string(syn) for syn in metadata_dict[KEY_SYNONYM_EN]
|
||||||
]
|
]
|
||||||
|
|
||||||
# Удаляем дубликаты синонимов, сохраняя порядок
|
# Удаляем дубликаты синонимов, сохраняя порядок
|
||||||
@@ -215,7 +215,7 @@ def validate_for_duplicates(
|
|||||||
query_params = []
|
query_params = []
|
||||||
for normalized_synonym in current_synonyms:
|
for normalized_synonym in current_synonyms:
|
||||||
synonym_query_parts.append(
|
synonym_query_parts.append(
|
||||||
f"EXISTS (SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE LOWER(json_each.value) = %s)"
|
f"EXISTS (SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM_EN}') WHERE LOWER(json_each.value) = %s)"
|
||||||
)
|
)
|
||||||
query_params.append(normalized_synonym)
|
query_params.append(normalized_synonym)
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ def validate_for_duplicates(
|
|||||||
if len(word) >= effective_min_word_len:
|
if len(word) >= effective_min_word_len:
|
||||||
word_search_conditions.append(
|
word_search_conditions.append(
|
||||||
f"""EXISTS (
|
f"""EXISTS (
|
||||||
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}')
|
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM_EN}')
|
||||||
WHERE LOWER(json_each.value) LIKE %s
|
WHERE LOWER(json_each.value) LIKE %s
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
@@ -343,7 +343,7 @@ def remove_conflicting_synonyms_from_duplicates(
|
|||||||
remove_conflicting_synonyms_from_duplicates(
|
remove_conflicting_synonyms_from_duplicates(
|
||||||
duplicates_queryset,
|
duplicates_queryset,
|
||||||
'j_label_metadata',
|
'j_label_metadata',
|
||||||
metadata_dict.get(KEY_SYNONYM) or [],
|
metadata_dict.get(KEY_SYNONYM_EN) or [],
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
# Нормализуем синонимы для удаления один раз
|
# Нормализуем синонимы для удаления один раз
|
||||||
@@ -354,10 +354,10 @@ def remove_conflicting_synonyms_from_duplicates(
|
|||||||
dup_metadata = getattr(duplicate_record, metadata_field_name) or {}
|
dup_metadata = getattr(duplicate_record, metadata_field_name) or {}
|
||||||
|
|
||||||
# Если в метаданных есть синонимы, удаляем те, которые совпадают с нашим списком
|
# Если в метаданных есть синонимы, удаляем те, которые совпадают с нашим списком
|
||||||
if KEY_SYNONYM in dup_metadata and isinstance(dup_metadata[KEY_SYNONYM], list):
|
if KEY_SYNONYM_EN in dup_metadata and isinstance(dup_metadata[KEY_SYNONYM_EN], list):
|
||||||
# Удаляем синонимы, которые совпадают с переданным списком
|
# Удаляем синонимы, которые совпадают с переданным списком
|
||||||
dup_metadata[KEY_SYNONYM] = [
|
dup_metadata[KEY_SYNONYM_EN] = [
|
||||||
syn for syn in dup_metadata[KEY_SYNONYM]
|
syn for syn in dup_metadata[KEY_SYNONYM_EN]
|
||||||
if normalize_string(syn) not in normalized_to_remove
|
if normalize_string(syn) not in normalized_to_remove
|
||||||
]
|
]
|
||||||
# Сохраняем обновленные метаданные
|
# Сохраняем обновленные метаданные
|
||||||
@@ -403,7 +403,7 @@ def build_search_report(
|
|||||||
search_words.update(re.split(r'\s+', main_value_normalized))
|
search_words.update(re.split(r'\s+', main_value_normalized))
|
||||||
|
|
||||||
# Собираем слова из синонимов метаданных
|
# Собираем слова из синонимов метаданных
|
||||||
metadata_synonyms = metadata_dict.get(KEY_SYNONYM) or []
|
metadata_synonyms = metadata_dict.get(KEY_SYNONYM_EN) or []
|
||||||
for synonym in metadata_synonyms:
|
for synonym in metadata_synonyms:
|
||||||
# Применяем супер-нормализацию к каждому синониму
|
# Применяем супер-нормализацию к каждому синониму
|
||||||
syn_normalized = super_normalize_string(synonym)
|
syn_normalized = super_normalize_string(synonym)
|
||||||
@@ -429,7 +429,7 @@ def build_search_report(
|
|||||||
|
|
||||||
# Получаем метаданные и синонимы
|
# Получаем метаданные и синонимы
|
||||||
dup_metadata = getattr(dup, metadata_field_name) or {}
|
dup_metadata = getattr(dup, metadata_field_name) or {}
|
||||||
dup_synonyms = dup_metadata.get(KEY_SYNONYM) or []
|
dup_synonyms = dup_metadata.get(KEY_SYNONYM_EN) or []
|
||||||
|
|
||||||
# Ищем слово в основном поле и синонимах (case-insensitive)
|
# Ищем слово в основном поле и синонимах (case-insensitive)
|
||||||
found_locations = []
|
found_locations = []
|
||||||
@@ -522,7 +522,7 @@ def validate_entity_for_admin_form(
|
|||||||
Примеры: 's_label' (для TbLabel), 's_artist' (для TbArtist), 's_style_name' (для TbMusicStyle)
|
Примеры: 's_label' (для TbLabel), 's_artist' (для TbArtist), 's_style_name' (для TbMusicStyle)
|
||||||
Если поле не существует в модели, будет выброшено AttributeError.
|
Если поле не существует в модели, будет выброшено AttributeError.
|
||||||
metadata_field_name: Имя поля метаданных модели. ОБЯЗАТЕЛЕН!
|
metadata_field_name: Имя поля метаданных модели. ОБЯЗАТЕЛЕН!
|
||||||
Примеры: 'j_label_metadata', 'j_artist_metadata', 'j_style_synonyms'
|
Примеры: 'j_label_metadata', 'j_artist_metadata', 'j_style_metadata'
|
||||||
Если поле не существует в модели, будет выброшено AttributeError.
|
Если поле не существует в модели, будет выброшено AttributeError.
|
||||||
request: HTTP request объект (опционально, используется для проверки GET параметра ignore_validate)
|
request: HTTP request объект (опционально, используется для проверки GET параметра ignore_validate)
|
||||||
|
|
||||||
@@ -667,7 +667,7 @@ def validate_entity_for_admin_form(
|
|||||||
remove_conflicting_synonyms_from_duplicates(
|
remove_conflicting_synonyms_from_duplicates(
|
||||||
duplicates_queryset,
|
duplicates_queryset,
|
||||||
metadata_field_name,
|
metadata_field_name,
|
||||||
metadata_dict.get(KEY_SYNONYM) or [], # Удаляем все синонимы текущей записи
|
metadata_dict.get(KEY_SYNONYM_EN) or [], # Удаляем все синонимы текущей записи
|
||||||
)
|
)
|
||||||
# Выходим без ошибки в админку, т.к. пользователь "проверил и уверен!"
|
# Выходим без ошибки в админку, т.к. пользователь "проверил и уверен!"
|
||||||
# Конфликтующие синонимы удалены, запись сохранится нормально
|
# Конфликтующие синонимы удалены, запись сохранится нормально
|
||||||
@@ -679,7 +679,7 @@ def validate_entity_for_admin_form(
|
|||||||
# Для каждого синонима текущей записи ищем, в каких записях он есть
|
# Для каждого синонима текущей записи ищем, в каких записях он есть
|
||||||
|
|
||||||
# Собираем текущие синонимы с нормализацией
|
# Собираем текущие синонимы с нормализацией
|
||||||
current_synonyms_list = metadata_dict.get(KEY_SYNONYM) or []
|
current_synonyms_list = metadata_dict.get(KEY_SYNONYM_EN) or []
|
||||||
|
|
||||||
# Строим словарь: {нормализованный синоним: {оригинальный синоним, запись1, запись2, ...}}
|
# Строим словарь: {нормализованный синоним: {оригинальный синоним, запись1, запись2, ...}}
|
||||||
synonym_to_records = {}
|
synonym_to_records = {}
|
||||||
@@ -694,7 +694,7 @@ def validate_entity_for_admin_form(
|
|||||||
# Ищем этот синоним в метаданных других записей
|
# Ищем этот синоним в метаданных других записей
|
||||||
for dup in duplicates_queryset:
|
for dup in duplicates_queryset:
|
||||||
dup_metadata = getattr(dup, metadata_field_name) or {}
|
dup_metadata = getattr(dup, metadata_field_name) or {}
|
||||||
dup_synonyms = dup_metadata.get(KEY_SYNONYM) or []
|
dup_synonyms = dup_metadata.get(KEY_SYNONYM_EN) or []
|
||||||
|
|
||||||
# Проверяем: есть ли текущий синоним в синонимах этой записи
|
# Проверяем: есть ли текущий синоним в синонимах этой записи
|
||||||
for dup_syn in dup_synonyms:
|
for dup_syn in dup_synonyms:
|
||||||
|
|||||||
@@ -317,7 +317,19 @@ LOGGING = {
|
|||||||
SLUG_MAX_LENGTH = 60
|
SLUG_MAX_LENGTH = 60
|
||||||
|
|
||||||
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
|
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
|
||||||
KEY_SYNONYM = 'SYNONYM'
|
KEY_SYNONYM_EN = 'SYN_EN' # Ключ для хранения синонимов на английском языке (для поиска по синонимам) и ДЛЯ ПАРСЕРОВ
|
||||||
|
KEY_SYNONYM_RU = 'SYN_RU' # Ключ для хранения синонимов на русском языке (для представления на фронтенд)
|
||||||
|
|
||||||
|
# Ключи и значения для метаданных картинок
|
||||||
|
KEY_IMAGE_TYPE = 'IMG_IS' # Ключ для хранения типа картинки
|
||||||
|
VALUE_IMAGE_ABSTRACT = 'abstract' # Абстрактная картинка (лого лейбла, фото исполнителя, типовая обложка альбома...)
|
||||||
|
VALUE_IMAGE_REAL = 'real' # Реальная картинка (актуально, для фото товаров в TbOffer)
|
||||||
|
KEY_IMAGE_FROM = 'IMG_FROM' # Ключ, о том, как получено изображение
|
||||||
|
VALUE_IMAGE_FROM_USER = '_adm' # Картинка загружена пользователем через админку
|
||||||
|
VALUE_IMAGE_FROM_PARSE = 'parse' # Картинка получена через парсер (например, с сайта Discogs, Bandcamp и т.д.)
|
||||||
|
KEY_IMAGE_URL = 'IMG_URL' # Ключ для хранения URL оригинальной картинки (обычно это должен прописать парсер)
|
||||||
|
KEY_IMAGE_NOTE = 'IMG_NOTE' # Ключ для хранения заметки о картинке
|
||||||
|
|
||||||
|
|
||||||
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
|
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
|
||||||
VALIDATE_KEY__MATCH_TYPE = 'MATCH_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 {
|
.codemirror-min-height-5 .cm-editor {
|
||||||
min-height: 8em; /* 5 строк */
|
min-height: 8em; /* 5 строк */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
* 4. Визуально показывает пользователю статус обхода валидации цветом кнопок
|
* 4. Визуально показывает пользователю статус обхода валидации цветом кнопок
|
||||||
*
|
*
|
||||||
* Универсальное решение: работает для любых форм в админке, не только для лейблов.
|
* Универсальное решение: работает для любых форм в админке, не только для лейблов.
|
||||||
|
*
|
||||||
|
* ВАЖНО: Этот скрипт работает независимо от порядка загрузки и инициализации CodeMirror.
|
||||||
|
* Используется стратегия проверки readyState и MutationObserver для динамического отслеживания
|
||||||
|
* появления новых элементов, включая CodeMirror div.codemirror.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Функция для добавления GET параметра к form action кнопки
|
// Функция для добавления GET параметра к form action кнопки
|
||||||
@@ -42,7 +46,8 @@ function markSubmitButtonsToIgnoreValidation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
// ===== ОСНОВНАЯ ФУНКЦИЯ ИНИЦИАЛИЗАЦИИ ВОТЧЕРА =====
|
||||||
|
function initFormWatcher() {
|
||||||
// Находим все submit-кнопки администратора
|
// Находим все submit-кнопки администратора
|
||||||
let submitButtons = document.querySelectorAll('input[type=submit]');
|
let submitButtons = document.querySelectorAll('input[type=submit]');
|
||||||
|
|
||||||
@@ -80,6 +85,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Отслеживаем изменения всех типов полей в форме
|
// Отслеживаем изменения всех типов полей в форме
|
||||||
|
// ВАЖНО: Используем MutationObserver для динамического отслеживания появления CodeMirror элементов
|
||||||
|
let watchedInputs = new WeakSet();
|
||||||
|
|
||||||
|
function attachWatchersToInputs() {
|
||||||
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
|
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
|
||||||
|
|
||||||
// Функция которая срабатывает при любом изменении
|
// Функция которая срабатывает при любом изменении
|
||||||
@@ -104,15 +113,23 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formInputs.forEach(function (input) {
|
formInputs.forEach(function (input) {
|
||||||
|
// Избегаем двойного присоединения слушателей (используем WeakSet)
|
||||||
|
if (!watchedInputs.has(input)) {
|
||||||
|
watchedInputs.add(input);
|
||||||
// Слушаем оба события: 'change' для обычных input/select
|
// Слушаем оба события: 'change' для обычных input/select
|
||||||
// и 'input' для CodeMirror и других редакторов
|
// и 'input' для CodeMirror и других редакторов
|
||||||
input.addEventListener('change', handleChange);
|
input.addEventListener('change', handleChange);
|
||||||
input.addEventListener('input', handleChange);
|
input.addEventListener('input', handleChange);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый раз присоединяем слушателей к уже существующим элементам
|
||||||
|
attachWatchersToInputs();
|
||||||
|
|
||||||
// Мониторим изменение класса force-ignore-validation на кнопках
|
// Мониторим изменение класса force-ignore-validation на кнопках
|
||||||
// Используем MutationObserver для отслеживания добавления/удаления класса
|
// Используем MutationObserver для отслеживания добавления/удаления класса
|
||||||
const observer = new MutationObserver(function (mutations) {
|
const classObserver = new MutationObserver(function (mutations) {
|
||||||
mutations.forEach(function (mutation) {
|
mutations.forEach(function (mutation) {
|
||||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||||
let btn = mutation.target;
|
let btn = mutation.target;
|
||||||
@@ -130,8 +147,67 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Настраиваем observer для отслеживания изменения класса
|
// Настраиваем observer для отслеживания изменения класса на кнопках
|
||||||
submitButtons.forEach(function (btn) {
|
submitButtons.forEach(function (btn) {
|
||||||
observer.observe(btn, {attributes: true, attributeFilter: ['class']});
|
classObserver.observe(btn, {attributes: true, attributeFilter: ['class']});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== ДИНАМИЧЕСКОЕ ОТСЛЕЖИВАНИЕ ПОЯВЛЕНИЯ НОВЫХ ЭЛЕМЕНТОВ (особенно CodeMirror) =====
|
||||||
|
// Используем MutationObserver для отслеживания появления новых элементов формы
|
||||||
|
// Это нужно на случай если CodeMirror инициализируется ПОСЛЕ загрузки скрипта
|
||||||
|
const formObserver = new MutationObserver(function (mutations) {
|
||||||
|
mutations.forEach(function (mutation) {
|
||||||
|
// Проверяем появились ли новые узлы (добавлены новые элементы)
|
||||||
|
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
|
||||||
|
// Проверяем есть ли среди добавленных элементов CodeMirror div или input/textarea
|
||||||
|
let hasNewFormElements = false;
|
||||||
|
mutation.addedNodes.forEach(function (node) {
|
||||||
|
if (node.nodeType === 1) { // Node.ELEMENT_NODE
|
||||||
|
if (node.classList && (node.classList.contains('codemirror') ||
|
||||||
|
node.tagName === 'INPUT' ||
|
||||||
|
node.tagName === 'TEXTAREA' ||
|
||||||
|
node.tagName === 'SELECT')) {
|
||||||
|
hasNewFormElements = true;
|
||||||
|
}
|
||||||
|
// Проверяем в потомках
|
||||||
|
if (node.querySelector && (node.querySelector('.codemirror') ||
|
||||||
|
node.querySelector('input:not([type=submit])') ||
|
||||||
|
node.querySelector('textarea') ||
|
||||||
|
node.querySelector('select'))) {
|
||||||
|
hasNewFormElements = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Если нашли новые элементы формы, повторно присоединяем слушателей
|
||||||
|
if (hasNewFormElements) {
|
||||||
|
attachWatchersToInputs();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Настраиваем observer для отслеживания изменений в форме (появления новых элементов)
|
||||||
|
formObserver.observe(form, {childList: true, subtree: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ИНИЦИАЛИЗАЦИЯ: Работает в любых условиях (до или после DOMContentLoaded) =====
|
||||||
|
// Стратегия:
|
||||||
|
// 1. Если document.readyState === 'loading', ждем DOMContentLoaded
|
||||||
|
// 2. Если document.readyState === 'interactive' или 'complete', запускаем сразу
|
||||||
|
// 3. Используем requestIdleCallback если доступен, иначе setTimeout
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
// DOM еще загружается, ждем события
|
||||||
|
document.addEventListener('DOMContentLoaded', initFormWatcher);
|
||||||
|
} else {
|
||||||
|
// DOM уже загружен (скрипт загрузился после DOMContentLoaded)
|
||||||
|
// Запускаем инициализацию асинхронно чтобы все элементы успели загрузиться
|
||||||
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
|
// Используем requestIdleCallback для загрузки в свободное время браузера
|
||||||
|
requestIdleCallback(initFormWatcher, {timeout: 2000});
|
||||||
|
} else {
|
||||||
|
// Fallback: используем setTimeout с минимальной задержкой
|
||||||
|
setTimeout(initFormWatcher, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user