Files
2018-lpon-site/_prj_blueprint/parser_architecture.md
T

415 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура парсера и валидации данных
**Дата:** 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()`)