add: валидатор форм для избежания дублей (2) оформлено в хелпер

This commit is contained in:
2026-06-20 18:37:24 +03:00
parent 3d9f0a6b5d
commit 6a178c9c9b
3 changed files with 122 additions and 58 deletions

View File

@@ -2,22 +2,15 @@
# Регистрируем модели с удобным интерфейсом.
from django import forms
from django.db import models
from django.forms import TextInput, Textarea, URLField
from django.forms import Textarea
from django.contrib import admin
from django.utils.html import format_html, mark_safe
from django.core.exceptions import ValidationError
from django.urls import reverse
from lpon_site.settings import (
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
VALIDATE_VAL__IS_DUPLICATE
)
from easy_thumbnails.files import get_thumbnailer
from .models import (
TbImage, TbArticle, TbArtist, TbItem, TbLabel, TbSeller,
TbOffer, TbSource, TbOfferHistory, TbMusicStyle
)
from .utils import validate_for_duplicates
from .utils import validate_entity_for_admin_form
# ============================================================================
# АДМИНИСТРИРОВАНИЕ TbImage
@@ -467,54 +460,18 @@ class LabelAdminForm(forms.ModelForm):
def clean(self):
"""
Валидируем форму: проверяем на дубликаты основного поля s_label
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label
"""
cleaned_data = super().clean()
# Получаем значения из очищенных данных
s_label = cleaned_data.get('s_label')
j_label_metadata = cleaned_data.get('j_label_metadata') or {}
if s_label:
# Вызываем валидатор для проверки дубликатов.
# Возвращает словарь: {VALIDATE_KEY__MATCH_TYPE: type, VALIDATE_KEY__VALUE: queryset, ...} или пустой словарь, если дубликатов нет
result = validate_for_duplicates(
model_class=TbLabel,
instance_pk=self.instance.pk, # pk экземпляра (None для новых)
main_field_value=s_label,
metadata_dict=j_label_metadata,
main_field_name='s_label',
metadata_field_name='j_label_metadata',
)
# Если найдены дубликаты, обрабатываем по типу совпадения
if VALIDATE_KEY__MATCH_TYPE in result:
match_type = result[VALIDATE_KEY__MATCH_TYPE]
duplicates_queryset = result[VALIDATE_KEY__VALUE]
if match_type == VALIDATE_VAL__IS_DUPLICATE:
# Точное совпадение основного поля — критическая ошибка
# Строим ссылки на дубликаты для быстрого перехода в админке
dup_links = []
for dup in duplicates_queryset:
# Получаем относительный URL для редактирования дубликата
# reverse вернет абсолютный путь, берем часть после /admin/
admin_url = reverse('admin:frontend_tblabel_change', args=[dup.pk])
# Делаем ссылку относительной (убираем начальный слэш)
rel_url = admin_url.lstrip('/')
dup_links.append(
f"<big><a href='{rel_url}'>#{dup.pk} '{dup.s_label}'</a></big>"
)
dup_list = ", ".join(dup_links)
raise ValidationError(
mark_safe(
f"ОШИБКА: Найден точный дубликат лейбла! "
f"Отредактируйте {dup_list} "
f"или используйте синонимы из найденной записи."
)
)
# Другие типы дубликатов обработаны будут позже
# Используем универсальный хелпер для проверки дубликатов
# Модель берется автоматически из self.Meta.model
validate_entity_for_admin_form(
self,
cleaned_data,
main_field_name='s_label',
metadata_field_name='j_label_metadata'
)
return cleaned_data

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
from lpon_site.settings import (
SLUG_MAX_LENGTH,
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
VALIDATE_VAL__IS_DUPLICATE
ValidateMatchType
)
logger = logging.getLogger(__name__)
@@ -251,7 +251,7 @@ def validate_for_duplicates(
exact_matches = exact_matches.exclude(pk=instance_pk)
if exact_matches.exists():
duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: VALIDATE_VAL__IS_DUPLICATE,
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
VALIDATE_KEY__VALUE: exact_matches,
})
return duplicates_found
@@ -265,3 +265,102 @@ def validate_for_duplicates(
# Когда все проверки прошли -- возвращаем пустой словарь
return duplicates_found
def validate_entity_for_admin_form(form_instance, cleaned_data,
main_field_name='s_label',
metadata_field_name='j_label_metadata'):
"""
Универсальный валидатор для админских форм.
Проверяет сущность на совпадения (дубликаты) с уже существующими записями.
Выбрасывает ValidationError с кликабельными ссылками на найденные дубликаты.
Используется во всех админских forms: LabelAdminForm, ArtistAdminForm, MusicStyleAdminForm и т.д.
Args:
form_instance: Экземпляр формы (self из clean методе)
cleaned_data: Очищенные данные формы
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name')
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata')
Raises:
ValidationError: Если найдены совпадения (дубликаты)
Пример использования в LabelAdminForm:
def clean(self):
cleaned_data = super().clean()
validate_entity_for_admin_form(
self,
cleaned_data,
main_field_name='s_label',
metadata_field_name='j_label_metadata'
)
return cleaned_data
"""
from django.urls import reverse
from django.utils.html import mark_safe
# Получаем класс модели из метаинформации формы
model_class = form_instance.Meta.model
# Получаем значения из формы
main_field_value = cleaned_data.get(main_field_name)
metadata_dict = cleaned_data.get(metadata_field_name) or {}
# Если основное поле не заполнено, пропускаем валидацию
if not main_field_value:
return
# Вызываем основной валидатор дубликатов
result = validate_for_duplicates(
model_class=model_class,
instance_pk=form_instance.instance.pk,
main_field_value=main_field_value,
metadata_dict=metadata_dict,
main_field_name=main_field_name,
metadata_field_name=metadata_field_name,
)
# Обрабатываем результаты проверки в зависимости от типа найденного совпадения
if VALIDATE_KEY__MATCH_TYPE in result:
match_type = result[VALIDATE_KEY__MATCH_TYPE]
duplicates_queryset = result[VALIDATE_KEY__VALUE]
# Используем match-case для удобной обработки разных типов совпадений
# С Enum вместо магических чисел код становится самодокументируемым
# В будущем легко добавить новые типы: ValidateMatchType.PARTIAL_MATCH = 2 и т.д.
match match_type:
case ValidateMatchType.IS_DUPLICATE:
# ОБРАБОТКА ТОЧНЫХ ДУБЛИКАТОВ
# Строим ссылки на найденные дубликаты для быстрого перехода в админке
dup_links = []
for dup in duplicates_queryset:
# Получаем admin URL автоматически через Django meta
model_name = model_class._meta.model_name
app_label = model_class._meta.app_label
admin_url = reverse(f'admin:{app_label}_{model_name}_change', args=[dup.pk])
# Делаем ссылку относительной (убираем начальный слэш для универсальности)
rel_url = admin_url.lstrip('/')
# Получаем значение основного поля из дубликата
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
# Объединяем все найденные дубликаты в один список
dup_list = ", ".join(dup_links)
# Выбрасываем ValidationError с HTML ссылками на дубликаты
raise ValidationError(
mark_safe(
f"ОШИБКА: Найдено совпадение! "
f"Отредактируйте {dup_list} "
f"или используйте синонимы из найденной записи."
)
)
case _:
# Неизвестный или не обработанный тип совпадения
# В будущем сюда можно добавить логирование неожиданных типов
pass

View File

@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
from pathlib import Path
import environ
import os
from enum import IntEnum
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -318,8 +319,15 @@ SLUG_MAX_LENGTH = 60
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
KEY_SYNONYM = 'SYNONYM'
# ДЛЯ МАТЧИНГА (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
# ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'
VALIDATE_KEY__MODEL = 'MODEL'
VALIDATE_KEY__VALUE = 'MATCH_VALUE'
VALIDATE_VAL__IS_DUPLICATE = 'exact' # Строгое совпадение (по имени, без учета регистра)
# Типы совпадений как Enum (более информативно чем просто числа, работает в match-case)
class ValidateMatchType(IntEnum):
"""Типы совпадений при поиске дубликатов."""
IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.)
# PARTIAL_MATCH = 2 # Частичное совпадение
# SYNONYM_MATCH = 3 # Совпадение по синониму