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 import forms
from django.db import models from django.forms import Textarea
from django.forms import TextInput, Textarea, URLField
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html, mark_safe 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 easy_thumbnails.files import get_thumbnailer
from .models import ( from .models import (
TbImage, TbArticle, TbArtist, TbItem, TbLabel, TbSeller, TbImage, TbArticle, TbArtist, TbItem, TbLabel, TbSeller,
TbOffer, TbSource, TbOfferHistory, TbMusicStyle TbOffer, TbSource, TbOfferHistory, TbMusicStyle
) )
from .utils import validate_for_duplicates from .utils import validate_entity_for_admin_form
# ============================================================================ # ============================================================================
# АДМИНИСТРИРОВАНИЕ TbImage # АДМИНИСТРИРОВАНИЕ TbImage
@@ -467,54 +460,18 @@ class LabelAdminForm(forms.ModelForm):
def clean(self): def clean(self):
""" """
Валидируем форму: проверяем на дубликаты основного поля s_label Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label
""" """
cleaned_data = super().clean() cleaned_data = super().clean()
# Получаем значения из очищенных данных # Используем универсальный хелпер для проверки дубликатов
s_label = cleaned_data.get('s_label') # Модель берется автоматически из self.Meta.model
j_label_metadata = cleaned_data.get('j_label_metadata') or {} validate_entity_for_admin_form(
self,
if s_label: cleaned_data,
# Вызываем валидатор для проверки дубликатов. main_field_name='s_label',
# Возвращает словарь: {VALIDATE_KEY__MATCH_TYPE: type, VALIDATE_KEY__VALUE: queryset, ...} или пустой словарь, если дубликатов нет metadata_field_name='j_label_metadata'
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"или используйте синонимы из найденной записи."
)
)
# Другие типы дубликатов обработаны будут позже
return cleaned_data return cleaned_data

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
from lpon_site.settings import ( from lpon_site.settings import (
SLUG_MAX_LENGTH, SLUG_MAX_LENGTH,
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE, VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
VALIDATE_VAL__IS_DUPLICATE ValidateMatchType
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -251,7 +251,7 @@ def validate_for_duplicates(
exact_matches = exact_matches.exclude(pk=instance_pk) exact_matches = exact_matches.exclude(pk=instance_pk)
if exact_matches.exists(): if exact_matches.exists():
duplicates_found.update({ duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: VALIDATE_VAL__IS_DUPLICATE, VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
VALIDATE_KEY__VALUE: exact_matches, VALIDATE_KEY__VALUE: exact_matches,
}) })
return duplicates_found return duplicates_found
@@ -265,3 +265,102 @@ def validate_for_duplicates(
# Когда все проверки прошли -- возвращаем пустой словарь # Когда все проверки прошли -- возвращаем пустой словарь
return duplicates_found 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 from pathlib import Path
import environ import environ
import os import os
from enum import IntEnum
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -318,8 +319,15 @@ SLUG_MAX_LENGTH = 60
# Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.) # Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.)
KEY_SYNONYM = 'SYNONYM' KEY_SYNONYM = 'SYNONYM'
# ДЛЯ МАТЧИНГА (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.) # ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.)
VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE' VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'
VALIDATE_KEY__MODEL = 'MODEL' VALIDATE_KEY__MODEL = 'MODEL'
VALIDATE_KEY__VALUE = 'MATCH_VALUE' 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 # Совпадение по синониму