minor: переименование

This commit is contained in:
2026-07-23 23:16:25 +03:00
parent 4dd34d5550
commit 18ff09fe3d
5 changed files with 0 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
# Архитектура парсера и валидации данных
**Дата:** 2026-07-15
**Последний обновленно:** 2026-07-15
**Статус:** В разработке ✏️
## Обзор проблемы
Система должна поддерживать импорт данных из различных источников:
- Excel/CSV файлы от продавцов и издателей
- Парсинг URL-страниц с каталогами
- Ручной ввод данных в админке
- Будущие API интеграции
**Основной вызов:** Валидация и обнаружение конфликтов (дубликаты, совпадения в синонимах) требует **пользовательского участия** для принятия решений. Нельзя просто блокировать или автоматически удалять/объединять данные.
## Текущее состояние (MVP)
### Модели и структура
```
┌─ TbSource (Excel, CSV, URL) ──┐
│ s_source_name │
│ l_source_type (excel/csv/url)│
│ k_source_to_seller ──────────┼──► TbSeller
│ source_file (FilerFileField) │
│ s_source_url │
│ j_source_metadata (struktura)│ (metadata описывает столбцы, вкладки и т.д.)
└─────────────────────────────────┘
TbOffer ◄──┐
TbSource ──┴─► TbItem ──────► TbArticle (текст, SEO, slug, картинка)
└──► TbLabel, TbArtist, TbMusicStyle
(все через TbArticle)
```
### Валидация (текущая реализация)
**Файл:** `lpon_site/frontend/utils_validators.py`
**Функция:** `validate_for_duplicates()`
- Проверяет основное поле (s_label, s_artist и т.д.)
- Проверяет синонимы в j_*_metadata[SYN_EN]
- Возвращает список найденных дубликатов с типом совпадения
**Типы совпадений:**
1. **EXACT_MATCH** — точное совпадение основного поля (s_label == s_label)
2. **FIND_IN_SYNONYM** — основное поле текущей записи найдено в синонимах другой
3. **EXACT_SYNONYM_MATCH** — синонимы текущей записи совпадают с синонимами другой
**Где вызывается:**
-В админке: `admin.py` (переопределение `clean()` формы) → показывает ошибку пользователю
-В моделях: `save()` методы → выбрасывают `ValidationError`
-В парсере: **НЕТ** (парсер еще не создан)
### Конфликты синонимов (TODO)
**Сценарий 1: FIND_IN_SYNONYM**
```python
# В БД уже есть
Artist(pk=1, s_artist="Beatles", j_artist_metadata={"SYN_EN": ["The Beatles", "Beatles"]})
# Парсер хочет создать
Artist(pk=None, s_artist="The Beatles", j_artist_metadata={"SYN_EN": []})
# Валидатор: основное поле "The Beatles" найдено в синонимах существующей записи!
# Текущее поведение: ValidationError (блокировка)
# TODO: Отправить в очередь, ждать решения админа
```
**Сценарий 2: EXACT_SYNONYM_MATCH**
```python
# В БД
Label(pk=1, s_label="Sony", j_label_metadata={"SYN_EN": ["Sony Records", "Sony Music"]})
# Парсер хочет
Label(pk=None, s_label="Sony Music", j_label_metadata={"SYN_EN": ["Sony Records", "Sony Music"]})
# Валидатор: синонимы совпадают!
# Текущее поведение: ValidationError
# TODO: Отправить в очередь, ждать решения админа
```
## Архитектура парсера (требуемая)
### Компоненты
```
┌──────────────────────────────────────────────────────────────┐
│ DJANGO ADMIN │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Приложение "Parser" (новое приложение) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Очередь валидации (ValidatorQueue model) │ │ │
│ │ │ Содержит: состояние экземпляра, ошибки, решение... │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ Admin list view + inline actions │ │ │
│ │ │ - Просмотр конфликта (дубликаты) │ │ │
│ │ │ - Кнопка "Создать новую запись" │ │ │
│ │ │ - Кнопка "Объединить/обновить синонимы" │ │ │
│ │ │ - Кнопка "Пропустить" │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ БРОКЕР ОЧЕРЕДИ (Redis) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ parser:validation:pending (ZSET) │ │
│ │ parser:validation:{queue_id}:data (STRING/JSON) │ │
│ │ parser:validation:{queue_id}:duplicates (JSON) │ │
│ │ parser:validation:{queue_id}:solution (STRING) │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ ПАРСЕР (Celery Task) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ parse_excel() / parse_csv() / parse_url() │ │
│ │ │ │
│ │ Логика: │ │
│ │ 1. Читать источник (файл/URL) │ │
│ │ 2. Нормализовать данные │ │
│ │ 3. Валидировать через validate_for_duplicates() │ │
│ │ 4. Если конфликт → создать запись в ValidatorQueue │ │
│ │ 5. Если OK → создать модель (TbLabel, TbArtist и т.д.)│ │
│ │ 6. Отправить уведомление админу (Redis/сигнал) │ │
│ │ 7. Продолжить обработку следующей строки │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ СИГНАЛЫ И УВЕДОМЛЕНИЯ (Django signals) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ post_validate_conflict → Redis уведомление админу │ │
│ │ post_admin_decision → Обновление очереди │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### Таблица ValidatorQueue (новая модель)
```python
class ValidatorQueue(models.Model):
"""
Очередь конфликтов, требующих решения админа.
"""
class Status(TextChoices):
PENDING = 'pending' # Ждёт решения админа
APPROVED = 'approved' # Админ одобрил - выполнить действие
REJECTED = 'rejected' # Админ отклонил
MERGED = 'merged' # Данные объединены с существующей записью
SKIPPED = 'skipped' # Заметка: пропущено
class ConflictType(TextChoices):
FIND_IN_SYNONYM = 'find_in_synonym' # Основное поле в синонимах
EXACT_SYNONYM_MATCH = 'exact_synonym_match' # Синонимы совпадают
OTHER = 'other'
# Основные поля
id = BigAutoField(primary_key=True)
k_source = ForeignKey(TbSource, ...) # Откуда пришли данные
l_conflict_type = CharField(choices=ConflictType)
# Данные для конфликта
l_model_type = CharField() # Какая модель (TbLabel, TbArtist и т.д.)
model_pk = IntegerField(null=True) # PK существующей записи в БД
j_incoming_data = JSONField() # Данные от парсера (весь объект)
j_duplicates = JSONField() # Результат validate_for_duplicates()
# Решение админа
l_status = CharField(choices=Status, default=Status.PENDING)
s_admin_decision = CharField() # 'create_new' / 'merge' / 'skip'
s_admin_notes = TextField() # Комментарий админа
# Сервисные поля
t_created = DateTimeField(auto_now_add=True)
t_resolved = DateTimeField(null=True)
k_resolved_by = ForeignKey(User, null=True) # Какой админ принял решение
class Meta:
verbose_name = 'Конфликт валидации'
ordering = ['-t_created']
indexes = [
Index(fields=['l_status', '-t_created']),
Index(fields=['k_source', 'l_status']),
]
```
## Процесс работы
### Фаза 1: Импорт данных (парсер)
```python
# tasks.py (Celery)
@celery.task
def parse_and_validate_excel(source_id: int):
"""
1. Читает Excel из TbSource
2. Парсит строки
3. Валидирует каждую запись
4. Создаёт или отправляет в очередь
"""
source = TbSource.objects.get(pk=source_id)
for row in read_excel(source.source_file):
try:
# Нормализуем данные
artist_data = {
's_artist': row['Artist Name'],
'j_artist_metadata': {'SYN_EN': [row.get('Aliases', '')]}
}
# Валидируем
duplicates = validate_for_duplicates(
model_class=TbArtist,
instance_pk=None,
main_field_value=artist_data['s_artist'],
metadata_dict=artist_data['j_artist_metadata'],
main_field_name='s_artist',
metadata_field_name='j_artist_metadata',
)
if duplicates:
# КОНФЛИКТ! Отправляем в очередь
queue_item = ValidatorQueue.objects.create(
k_source=source,
l_model_type='TbArtist',
l_conflict_type=duplicates[0][VALIDATE_KEY__MATCH_TYPE],
j_incoming_data=artist_data,
j_duplicates=duplicates,
)
# Отправляем сигнал (Redis уведомление админу)
send_to_redis_queue('parser:conflicts:new', {
'queue_id': queue_item.id,
'model': 'TbArtist',
'conflict': duplicates[0]['match_type'],
'data': artist_data,
})
else:
# Нет конфликта - создаём запись
artist = TbArtist.objects.create(**artist_data)
logger.info(f"Artist created: {artist.id}")
except Exception as e:
logger.error(f"Parse error in row {row}: {e}")
continue
```
### Фаза 2: Админка (приложение Parser)
```python
# admin.py (новое приложение parser)
@admin.register(ValidatorQueue)
class ValidatorQueueAdmin(admin.ModelAdmin):
"""
Админка для работы с очередью конфликтов.
"""
list_display = [
'id', 'l_model_type', 'l_conflict_type', 'l_status',
'source_link', 'duplicates_summary', 't_created'
]
list_filter = ['l_status', 'l_conflict_type', 'l_model_type', 't_created']
readonly_fields = ['j_incoming_data', 'j_duplicates', 'k_source']
fieldsets = (
('Конфликт', {
'fields': ['l_model_type', 'l_conflict_type', 'k_source', 'model_pk']
}),
('Входящие данные', {
'fields': ['j_incoming_data']
}),
('Найденные дубликаты', {
'fields': ['j_duplicates']
}),
('Решение админа', {
'fields': ['s_admin_decision', 's_admin_notes', 'l_status']
}),
)
actions = ['action_create_new', 'action_merge_synonyms', 'action_skip']
def action_create_new(self, request, queryset):
"""Админ решил: создать новую запись, игнорируя дубликаты"""
for item in queryset:
model_class = get_model_class(item.l_model_type)
instance = model_class(**item.j_incoming_data)
instance.save(skip_validation=True) # Пропускаем валидацию
item.l_status = ValidatorQueue.Status.APPROVED
item.s_admin_decision = 'create_new'
item.k_resolved_by = request.user
item.t_resolved = now()
item.save()
```
### Фаза 3: Обработка решения админа
```python
# signals.py (новое приложение parser)
@receiver(post_save, sender=ValidatorQueue)
def on_admin_decision(sender, instance, created=False, **kwargs):
"""
Обработать решение админа и применить действие.
"""
if created or instance.l_status != ValidatorQueue.Status.APPROVED:
return
if instance.s_admin_decision == 'create_new':
# Админ одобрил создание новой записи
model_class = get_model_class(instance.l_model_type)
model_instance = model_class(**instance.j_incoming_data)
model_instance.save(skip_validation=True)
elif instance.s_admin_decision == 'merge':
# Админ решил объединить (добавить синонимы, и т.д.)
existing_pk = instance.model_pk
incoming_data = instance.j_incoming_data
model_class = get_model_class(instance.l_model_type)
existing = model_class.objects.get(pk=existing_pk)
# Добавляем синонимы из входящих данных
existing_meta = existing.j_*_metadata or {}
existing_synonyms = existing_meta.get(KEY_SYNONYM_EN, [])
incoming_synonyms = incoming_data.get('j_*_metadata', {}).get(KEY_SYNONYM_EN, [])
# Объединяем и сохраняем
existing_meta[KEY_SYNONYM_EN] = list(set(existing_synonyms + incoming_synonyms))
existing.j_*_metadata = existing_meta
existing.save(skip_validation=True)
```
## Файлы для создания
### 1. Новое приложение `parser`
```
lpon_site/parser/
├── __init__.py
├── models.py # ValidatorQueue
├── admin.py # ValidatorQueueAdmin + actions
├── signals.py # Обработка решений админа
├── tasks.py # Celery tasks для парсинга
├── apps.py
└── migrations/
```
### 2. Изменения в existing коде
**settings.py:**
```python
INSTALLED_APPS = [
...,
'parser', # Новое приложение
]
# Конфигурация Celery (если еще нет)
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
```
**frontend/models.py:**
```python
# В методе save() моделей добавить параметр
def save(self, *args, skip_validation=False, **kwargs):
if not skip_validation:
# Стандартная валидация
validate_and_raise_for_duplicates(...)
super().save(*args, **kwargs)
```
**frontend/utils.py:**
```python
# Добавить функцию отправки в Redis
def send_to_redis_queue(queue_name: str, data: dict):
"""Отправить уведомление в Redis"""
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
r.lpush(queue_name, json.dumps({
'timestamp': datetime.now().isoformat(),
**data
}))
```
## TODO задачи
- [ ] Создать приложение `parser` с моделью `ValidatorQueue`
- [ ] Реализовать админку `ValidatorQueueAdmin` с actions
- [ ] Написать Celery tasks для парсинга Excel/CSV
- [ ] Добавить обработку решений админа через signals
- [ ] Интегрировать Redis уведомления админу
- [ ] Добавить параметр `skip_validation` в save() методы
- [ ] Написать тесты для парсера и валидации
- [ ] Документация для парсера (как использовать)
## Примечания
1. **Redis vs БД для очереди?** → Используем БД (ValidatorQueue модель) как основное хранилище, Redis только для уведомлений админу в реальном времени
2. **Async валидация?** → Парсер работает в Celery, валидация синхронна внутри задачи
3. **Миграция истории?** → Поле `t_history_created` уже `editable=True` в TbOfferHistory, так что админ может вручную добавлять исторические данные
4. **Конфликты в админке?**`ValidatorQueue` блокирует парсер от создания дубликатов. Админ сам решает что делать.
## Ссылки на существующий код
- Валидаторы: `lpon_site/frontend/utils_validators.py`
- Модели: `lpon_site/frontend/models.py` (методы save() для TbArtist, TbItem, TbLabel, TbSeller, TbMusicStyle)
- TODO про Redis: `lpon_site/frontend/utils.py` (в функции `create_or_get_related_article()`)
- TODO про историю: `lpon_site/frontend/models.py``TbOffer.save()`)