doc: Архитектура парсера и валидации данных (драфт)
This commit is contained in:
@@ -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()`)
|
||||
Reference in New Issue
Block a user