diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index eadc45d..4c08710 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -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"#{dup.pk} '{dup.s_label}'" - ) - - 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 diff --git a/lpon_site/frontend/utils.py b/lpon_site/frontend/utils.py index ccf8fb5..6cdb014 100644 --- a/lpon_site/frontend/utils.py +++ b/lpon_site/frontend/utils.py @@ -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"#{dup.pk} '{dup_value}'") + + # Объединяем все найденные дубликаты в один список + dup_list = ", ".join(dup_links) + + # Выбрасываем ValidationError с HTML ссылками на дубликаты + raise ValidationError( + mark_safe( + f"ОШИБКА: Найдено совпадение! " + f"Отредактируйте {dup_list} " + f"или используйте синонимы из найденной записи." + ) + ) + + case _: + # Неизвестный или не обработанный тип совпадения + # В будущем сюда можно добавить логирование неожиданных типов + pass + diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index 1738f15..d839b36 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -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 # Совпадение по синониму +