From e970d59173e839548f655eba0ff1948f11449cd0 Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 21 Jun 2026 15:31:41 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=84=D0=BE=D1=80=D0=BC,=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9=20(05)=20=D0=B8=D0=B7=D0=B1=D0=B5?= =?UTF-8?q?=D0=B6=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B2=20=D1=81=D0=B8=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D1=85=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/admin.py | 39 ++++++++- lpon_site/frontend/utils.py | 143 +++++++++++++++++++++++++++----- lpon_site/lpon_site/settings.py | 2 +- 3 files changed, 160 insertions(+), 24 deletions(-) diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index 4c08710..783363a 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -430,14 +430,19 @@ class LabelAdminForm(forms.ModelForm): Кастомная форма для админки лейблов (Label). Добавляет виджеты CodeMirror для текстовых полей """ + class Meta: model = TbLabel fields = ('s_label', 'k_label_to_article', 'j_label_metadata',) def __init__(self, *args, **kwargs): """ - При инициализации формы подгружаем + При инициализации формы подгружаем CodeMirror. + Получаем request из kwargs, переданных из get_form_kwargs в AdminClass. """ + # Извлекаем request из kwargs если он есть + self.request = kwargs.pop('request', None) + # Атрибуты для активации CodeMirror редактора codemirror_attrs = { 'data-codemirror-editor': '1', @@ -446,6 +451,7 @@ class LabelAdminForm(forms.ModelForm): super().__init__(*args, **kwargs) + # Активируем CodeMirror и устанавливаем классы для реальных полей self.fields['s_label'].widget = Textarea(attrs={ **codemirror_attrs, @@ -458,19 +464,23 @@ class LabelAdminForm(forms.ModelForm): 'data-language': 'json', }) + def clean(self): """ - Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label + Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label. + Используем GET параметр ignore_validate для пропуска валидации при переотправке. """ cleaned_data = super().clean() # Используем универсальный хелпер для проверки дубликатов # Модель берется автоматически из self.Meta.model + # Передаем request для проверки GET параметра ignore_validate validate_entity_for_admin_form( self, cleaned_data, main_field_name='s_label', - metadata_field_name='j_label_metadata' + metadata_field_name='j_label_metadata', + request=self.request, ) return cleaned_data @@ -489,6 +499,7 @@ class LabelAdmin(admin.ModelAdmin): 'codemirror/editor.js', # Основной CodeMirror 'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной ) + list_display = ('id', 's_label', 't_label_created') list_display_links = ('id', 's_label',) search_fields = ('s_label',) @@ -515,6 +526,28 @@ class LabelAdmin(admin.ModelAdmin): }), ) + def get_form(self, request, obj=None, **kwargs): + """ + Переопределяем get_form чтобы передать request в форму. + Создаем оборачивающий класс который передаст request в __init__. + """ + FormClass = super().get_form(request, obj, **kwargs) + + # Сохраняем request в замыкании для доступа в классе + request_ref = request + + class FormWithRequest(FormClass): + """Оборачивающий класс который передает request при инстанцировании""" + def __init__(form_instance, *args, **init_kwargs): + # Добавляем request в kwargs перед вызовом __init__ родителя + init_kwargs['request'] = request_ref + super().__init__(*args, **init_kwargs) + + return FormWithRequest + + + + # ================ # АДМИН-ПАНЕЛЬ ДЛЯ ПРОДАВЦА/SELLER # diff --git a/lpon_site/frontend/utils.py b/lpon_site/frontend/utils.py index 87ac51e..ea77572 100644 --- a/lpon_site/frontend/utils.py +++ b/lpon_site/frontend/utils.py @@ -242,26 +242,55 @@ def validate_for_duplicates( duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__} + # ПОДГОТОВКА: Получаем базовый queryset (все записи кроме текущей, если редактируем) + # Это ленивый запрос - запрос выполнится, только когда мы применим фильтры + records_to_check = model_class.objects.all() + if instance_pk is not None: + # При редактировании исключаем текущую запись из поиска, чтобы не найти "самого себя" + records_to_check = records_to_check.exclude(pk=instance_pk) + # ПРОВЕРКА 1: EXACT MATCH (точное совпадение основного поля) # Ищем: есть ли другая запись с точно таким же main_field_value? filter_kwargs = {f"{main_field_name}__exact": normalized_main_value} - exact_matches = model_class.objects.filter(**filter_kwargs) - if instance_pk is not None: - # При редактировании, чтобы не найти "самого себя" как дубликат, исключаем текущую запись из поиска - exact_matches = exact_matches.exclude(pk=instance_pk) + exact_matches = records_to_check.filter(**filter_kwargs) if exact_matches.exists(): duplicates_found.update({ VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE, VALIDATE_KEY__VALUE: exact_matches, }) return duplicates_found - # for other_record in exact_matches: - # other_main_value = str(getattr(other_record, main_field_name, "")) - # duplicates_found[VALIDATE_KEY__VALUE].append({ - # MATCH__KEY_PK: other_record.pk, - # 's_label': other_main_value, - # 'matched_value': normalized_main_value, - # }) + + # ПРОВЕРКА 2: SYNONYM MATCH (совпадение с синонимами в метаданных других записей) + # Ищем: есть ли текущее значение main_field в синонимах других записей? + # Например, если у записи A синонимы=['Sony Music', 'SME Records'], + # а мы добавляем запись B с основным полем 'Sony Music', это совпадение! + + # Используем RawSQL для работы с JSON функциями SQLite + # json_each(j_label_metadata, '$.SYNONYM') развертывает массив синонимов в отдельные строки + # Это необходимо т.к. Django ORM для SQLite не поддерживает __contains для JSON полей + from django.db.models.expressions import RawSQL + + # Строим RawSQL запрос для поиска в JSON массиве синонимов + # json_each распарсивает массив и ищет совпадение со значением + synonym_matches = records_to_check.annotate( + has_synonym=RawSQL( + f""" + EXISTS ( + SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') + WHERE json_each.value = %s + ) + """, + (normalized_main_value,) + ) + ).filter(has_synonym=True) + + # Если найдены совпадения в синонимах - возвращаем все найденные записи + if synonym_matches.exists(): + duplicates_found.update({ + VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.FIND_IN_SYNONYM, + VALIDATE_KEY__VALUE: synonym_matches, + }) + return duplicates_found # Когда все проверки прошли -- возвращаем пустой словарь return duplicates_found @@ -269,7 +298,8 @@ def validate_for_duplicates( def validate_entity_for_admin_form(form_instance, cleaned_data, main_field_name='s_label', - metadata_field_name='j_label_metadata'): + metadata_field_name='j_label_metadata', + request=None): """ Универсальный валидатор для админских форм. @@ -283,9 +313,10 @@ def validate_entity_for_admin_form(form_instance, cleaned_data, cleaned_data: Очищенные данные формы main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name') metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata') + request: HTTP request объект (опционально, используется для проверки GET параметра ignore_validate) Raises: - ValidationError: Если найдены совпадения (дубликаты) + ValidationError: Если найдены совпадения (дубликаты) и GET параметр не установлен Пример использования в LabelAdminForm: def clean(self): @@ -294,12 +325,18 @@ def validate_entity_for_admin_form(form_instance, cleaned_data, self, cleaned_data, main_field_name='s_label', - metadata_field_name='j_label_metadata' + metadata_field_name='j_label_metadata', + request=self.request if hasattr(self, 'request') else None, ) return cleaned_data """ from django.utils.html import mark_safe + # ПЕРЕД ВАЛИДАЦИЕЙ: проверяем, нажата ли submit-кнопка с измененным value='ignore_validate' + # Если пользователь нажал нашу кнопку подтверждения, она меняет value админских кнопок на 'ignore_validate' + if request and any(request.POST.get(btn) == 'ignore_validate' for btn in ['_save', '_addanother', '_continue']): + return + # Получаем класс модели из метаинформации формы model_class = form_instance.Meta.model @@ -326,15 +363,15 @@ def validate_entity_for_admin_form(form_instance, cleaned_data, match_type = result[VALIDATE_KEY__MATCH_TYPE] duplicates_queryset = result[VALIDATE_KEY__VALUE] + # В dup_links формируем ссылки на найденные дубликаты для быстрого перехода в админке + dup_links = [] + # Используем match-case для удобной обработки разных типов совпадений # С Enum вместо магических чисел код становится самодокументируемым # В будущем легко добавить новые типы: ValidateMatchType.PARTIAL_MATCH = 2 и т.д. match match_type: case ValidateMatchType.IS_DUPLICATE: # ОБРАБОТКА ТОЧНЫХ ДУБЛИКАТОВ - # Строим ссылки на найденные дубликаты для быстрого перехода в админке - dup_links = [] - for dup in duplicates_queryset: # Относительная ссылка зависит от режима админки: # При создании: /admin/app/model/add/ → ../456/change/ @@ -347,12 +384,73 @@ def validate_entity_for_admin_form(form_instance, cleaned_data, # Объединяем все найденные дубликаты в один список dup_list = ", ".join(dup_links) - # Выбрасываем ValidationError с HTML ссылками на дубликаты + # Для случая IS_DUPLICATE отключена проверка force_ignore_validate, т.к. это критическая ситуация + # и проверяемом поле часто unique=True на уровне модели. raise ValidationError( mark_safe( - f"ОШИБКА: Найдено совпадение! " - f"Отредактируйте {dup_list} " + f"ОШИБКА: Найдено ПОЛНОЕ совпадение! " + f"Измените название или отредактируйте {dup_list}." + ) + ) + + case ValidateMatchType.FIND_IN_SYNONYM: + # ОБРАБОТКА СОВПАДЕНИЙ В СИНОНИМАХ + for dup in duplicates_queryset: + rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/" + dup_value = getattr(dup, main_field_name, '?') + dup_links.append(f"#{dup.pk} '{dup_value}'") + dup_list = ", ".join(dup_links) + + # Кнопка подтверждения создания несмотря на синонимы + # При клике меняет value всех submit-кнопок на 'ignore_validate' и отправляет форму + # Если пользователь потом меняет данные - вотчер вернет оригинальные значения + confirmation_button = ''' +

+ + + Форма будет переотправлена без проверки синонимов + + + + ''' + + raise ValidationError( + mark_safe( + f"ВНИМАНИЕ: Найдено совпадение в синонимах! " + f"Проверьте {dup_list} " f"или используйте синонимы из найденной записи." + f"{confirmation_button}" ) ) @@ -532,3 +630,8 @@ def update_synonyms_in_metadata( if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list): metadata_dict[KEY_SYNONYM] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM])) + # ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ ===== + # Для JSONField нужно явно сообщить что мы изменили содержимое + # иначе Django может не сохранить изменения + setattr(instance, metadata_field_name, metadata_dict) + diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index d839b36..5c26ff0 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -328,6 +328,6 @@ VALIDATE_KEY__VALUE = 'MATCH_VALUE' class ValidateMatchType(IntEnum): """Типы совпадений при поиске дубликатов.""" IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.) - # PARTIAL_MATCH = 2 # Частичное совпадение + FIND_IN_SYNONYM = 2 # Частичное совпадение # SYNONYM_MATCH = 3 # Совпадение по синониму