diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index dd036e5..eadc45d 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -6,11 +6,18 @@ from django.db import models from django.forms import TextInput, Textarea, URLField 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 # ============================================================================ # АДМИНИСТРИРОВАНИЕ TbImage @@ -458,6 +465,59 @@ class LabelAdminForm(forms.ModelForm): 'data-language': 'json', }) + def clean(self): + """ + Валидируем форму: проверяем на дубликаты основного поля 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"или используйте синонимы из найденной записи." + ) + ) + # Другие типы дубликатов обработаны будут позже + + return cleaned_data + # Админ для лейбла (Label) class LabelAdmin(admin.ModelAdmin): """Админ для лейблов""" diff --git a/lpon_site/frontend/utils.py b/lpon_site/frontend/utils.py index 893a609..ccf8fb5 100644 --- a/lpon_site/frontend/utils.py +++ b/lpon_site/frontend/utils.py @@ -1,33 +1,82 @@ # frontend/utils.py # Служебные функции и хелперы проекта -from bs4 import BeautifulSoup -from html import unescape -from lpon_site.settings import SLUG_MAX_LENGTH -from etpgrf.config import HANGING_PUNCTUATION_SPACE_CHARS as SPACE_CHARS import re import pytils import random import logging +from bs4 import BeautifulSoup +from html import unescape +from etpgrf.config import HANGING_PUNCTUATION_SPACE_CHARS as SPACE_CHARS +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 +) logger = logging.getLogger(__name__) +def normalize_string(s: str) -> str: + """ + Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы. + + Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.). + + Args: + s: Строка для нормализации + + Returns: + str: Нормализованная строка (или пустая если была пуста) + + Пример: + >> normalize_string(" Sony Music ") + 'Sony Music' + >> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами + 'Sony Music' + """ + if not s: + return "" + + result = str(s) + + # Удаляем невидимые символы (не заменять, а полностью удалять) + result = result.translate({ + ord("\xad"): None, # символ мягкого переноса + ord("\u200b"): None, # символ нулевой ширины (zero-width space) + ord("\u200c"): None, # символ нулевой ширины (zero-width non-joiner) + ord("\u200d"): None, # символ Zero Width Joiner (ZWJ) + ord("\u2060"): None, # символ Word Joiner (WJ) + ord("\ufeff"): None, # символ Zero Width No-Break Space (BOM) + }) + + # Все типы пробельных символов для замены на обычный пробел + all_spaces = SPACE_CHARS | frozenset([ + "\u00a0", # non-breaking space ( ) + "\u202F", # narrow no-break space (тонкий неразрывный пробел) + ]) + + # Заменяем ВСЕ типы пробелов на обычный пробел + for space_char in all_spaces: + result = result.replace(space_char, " ") + + # Удаляем начальные/конечные пробелы и нормализуем множественные пробелы + # Финальная подстраховка: regex для ВСЕХ unicode whitespace символов (даже неизвестных) + result = re.sub(r'\s+', ' ', result).strip() + return result + + def safe_html_special_symbols(s: str) -> str: """Преобразует HTML-фрагмент в чистый текст. - Удаляет все HTML-теги и декодирует HTML-мнемоники в Unicode, убирает невидимые символы и нормализует пробелы. - - Обработка пробелов: - - Заменяет ВСЕ типы пробелов из SPACE_CHARS на обычный пробел - - Добавляет явно: \\u00a0 (non-breaking space) и \\u202F (narrow no-break space) - - Удаляет нулевой ширины символы (ZWJ, zero-width space и т.д.) - - Нормализует множественные пробелы в один + Удаляет все HTML-теги и декодирует HTML-мнемоники в Unicode. + Затем нормализует пробелы через normalize_string(). Args: s: Строка, которую надо очистить (с возможной HTML-разметкой). Returns: - str: Чистый текст без HTML-разметки и спецсимволов. + str: Чистый текст без HTML-разметки, спецсимволов и нормализованный. Example: >> safe_html_special_symbols('
Привет мир!
') @@ -51,27 +100,8 @@ def safe_html_special_symbols(s: str) -> str: result = soup.get_text() result = unescape(result) - # Убираем символы, которые нужно удалить (не заменять, а удалять) - result = result.translate({ - ord("\xad"): None, # символ мягкого переноса - ord("\u200b"): None, # символ нулевой ширины (zero-width space) - ord("\u200c"): None, # символ нулевой ширины (zero-width non-joiner) - ord("\u200d"): None, # символ Zero Width Joiner (ZWJ) - ord("\u2060"): None, # символ Word Joiner (WJ) - ord("\ufeff"): None, # символ Zero Width No-Break Space (BOM) - }) - - # Заменяем все типы пробелов из SPACE_CHARS из библиотеки etpgrf на обычный пробел - # Важно сделать это после обработки "мягкого переноса" потому что он включен в SPACE_CHARS (frozenset) - all_spaces = SPACE_CHARS | frozenset([ - "\u00a0", # non-breaking space ( ) - "\u202F", # narrow no-break space (нет мнемоники) — тонкий неразрывный пробел - ]) - for space_char in all_spaces: - result = result.replace(space_char, " ") - - # Нормализуем пробелы (удаляем множественные пробелы и приводим к стандарту) - return " ".join(result.split()) + # Нормализуем: удаляем невидимые символы, все типы пробелов, дубли и края + return normalize_string(result) def make_slug(slug_it: str, max_length: int | None = None, slug_default: str = "content") -> str: @@ -122,3 +152,116 @@ def make_slug(slug_it: str, max_length: int | None = None, slug_default: str = " # Если все еще пусто — генерируем fallback (БЕЗ обрезания!) return slug or f"{slug_default}-{random.randint(1, 4095):03x}" + + + +def validate_for_duplicates( + model_class, + instance_pk: int | None, + main_field_value: str, + metadata_dict: dict | None, + main_field_name: str | None = None, + metadata_field_name: str | None = None, +) -> dict: + """ + Универсальный валидатор для проверки дубликатов в моделях БД. + + Находит дубликаты и возвращает их список. + Логика обработки (исключение, логирование, API ответ) — дело вызывающего кода. + + Args: + model_class: Класс модели для поиска (TbLabel, TbArtist и т.д.). Обязателен! + instance_pk: PK текущей записи или None для новых записей + main_field_value: Значение основного поля для проверки (s_label, s_artist и т.д.). Не может быть пусто! + metadata_dict: Словарь метаданных, содержащий синонимы. Может быть None или {} + main_field_name: Имя основного поля модели (s_label, s_artist, s_style_name). Обязателен! + metadata_field_name: Имя поля метаданных (j_label_metadata, j_artist_metadata). Обязателен! + + Returns: + list: Список найденных дубликатов (может быть пустой) + Каждый элемент: {'pk': int, 's_label': str, 'matched_value': str, 'match_type': str, ...} + + Примеры использования: + # В админке + duplicates = validate_for_duplicates( + model_class=TbLabel, + instance_pk=self.instance.pk, + main_field_value=self.cleaned_data['s_label'], + metadata_dict=self.instance.j_label_metadata, + main_field_name='s_label', + metadata_field_name='j_label_metadata', + ) + if duplicates: + raise ValidationError("Найдены дубликаты...") + + # В парсере + duplicates = validate_for_duplicates(...) + if duplicates: + logger.warning(f"Дубликаты найдены: {duplicates}") + continue + """ + # ===== ВАЛИДАЦИЯ ПАРАМЕТРОВ ===== + + # Проверяем, что model_class и имена полей переданы + if model_class is None: + raise TypeError("model_class is required and cannot be None") + if main_field_name is None: + raise TypeError( + "main_field_name is required and cannot be None.\nExample: 's_label', 's_artist', 's_style_name'" + ) + if metadata_field_name is None: + raise TypeError( + "metadata_field_name is required and cannot be None.\nExample: 'j_label_metadata', 'j_artist_metadata'" + ) + + # Проверяем, что поля существуют в модели + for field_name in [main_field_name, metadata_field_name]: + if not hasattr(model_class, field_name): + raise AttributeError( + f"Model '{model_class.__name__}' has no field '{field_name}'" + ) + + # Проверяем main_field_value (не может быть пусто даже после нормализации) + if not main_field_value or not str(main_field_value).strip(): + raise ValidationError("main_field_value cannot be empty or whitespace") + + # Нормализуем основное поле (удаляем пробелы в начале/конце и дублирующие) + normalized_main_value = normalize_string(main_field_value) + + # Проверяем, что после нормализации остался какой-то текст + if not normalized_main_value: + raise ValidationError("main_field_value becomes empty after normalization") + + # Проверяем metadata_dict (если передан, должен быть dict или None) + if metadata_dict is not None and not isinstance(metadata_dict, dict): + raise TypeError( + f"metadata_dict must be dict or None, got {type(metadata_dict).__name__}" + ) + + # ===== ОСНОВНАЯ ЛОГИКА ===== + + duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__} + + # ПРОВЕРКА 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) + if exact_matches.exists(): + duplicates_found.update({ + VALIDATE_KEY__MATCH_TYPE: VALIDATE_VAL__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, + # }) + + # Когда все проверки прошли -- возвращаем пустой словарь + return duplicates_found diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index 3b3bbf0..1738f15 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -316,4 +316,10 @@ LOGGING = { SLUG_MAX_LENGTH = 60 # Ключи для типовых параметров в мета-полях (для TbLabel, TbSeller, TbArtist, TbMusicStyle и т.д.) -KEY_SYNONYM = 'SYNONYM' \ No newline at end of file +KEY_SYNONYM = 'SYNONYM' + +# ДЛЯ МАТЧИНГА (поиска похожих исполнителей, стилей и т.д. (используется в 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' # Строгое совпадение (по имени, без учета регистра)