# frontend/utils.py # Служебные функции и хелперы проекта 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 django.db.models import QuerySet from django.db.models.expressions import RawSQL from django.utils.html import mark_safe from lpon_site.settings import ( SLUG_MAX_LENGTH, KEY_SYNONYM, VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE, ValidateMatchType, MIN_SYNONYM_WORD_LENGTH ) logger = logging.getLogger(__name__) def normalize_string(s: str) -> str: """ Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы. Также приводит к нижнему регистру для case-insensitive сравнения при поиске дублей. Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.). Args: s: Строка для нормализации Returns: str: Нормализованная строка в нижнем регистре (или пустая если была пуста) Пример: >> normalize_string(" Sony Music ") 'sony music' >> normalize_string("SONY MUSIC") 'sony music' >> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами 'sony music' """ if not s: return "" result = str(s) # Преобразуем в нижний регистр для case-insensitive сравнения # (регистр не должен быть значимым при поиске дублей артистов/лейблов/стилей) result = result.lower() # Удаляем невидимые символы (не заменять, а полностью удалять) 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 super_normalize_string(s: str) -> str: """ Супер-нормализация строки для проверки частичного совпадения слов (PARTIAL_MATCH). Выполняет глубокую нормализацию для сравнения словарных совпадений: - Очищает HTML через safe_html_special_symbols() (которая вызывает normalize_string()) - Удаляет все символы кроме букв, цифр и пробелов - Сохраняет буквы и цифры ВСЕХ языков (Unicode-safe) - Приводит к нижнему регистру (через safe_html_special_symbols → normalize_string) - Нормализует пробелы (множественные → одиночные) Результат используется для поиска словарных совпадений: "The Beatles" и "Beatles, The" оба станут: "the beatles" Затем разбиваются на слова: ["the", "beatles"] Args: s: Строка для нормализации Returns: str: Строка со словами, разделенными пробелами (в нижнем регистре) Пример: >> super_normalize_string("The Beatles (Rock)") 'the beatles rock' >> super_normalize_string("Beatles, The - Rock Band!") 'beatles the rock band' """ if not s: return "" # Сначала очищаем HTML и спецсимволы через существующую функцию # (safe_html_special_symbols вызывает normalize_string(), который уже приводит к нижнему регистру) cleaned = safe_html_special_symbols(s) # Удаляем все символы кроме букв, цифр и пробелов (Unicode-safe) # \w в Python regex с флагом UNICODE включает: буквы всех языков, цифры и подчеркивание # Используем [^\w\s] для удаления всего кроме слов и пробелов, затем исключаем подчеркивание cleaned = re.sub(r'[^\w\s]|_', '', cleaned, flags=re.UNICODE) # Нормализуем пробелы (множественные → одиночные) cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned def safe_html_special_symbols(s: str) -> str: """Преобразует HTML-фрагмент в чистый текст. Удаляет все HTML-теги и декодирует HTML-мнемоники в Unicode. Затем нормализует пробелы через normalize_string(). Args: s: Строка, которую надо очистить (с возможной HTML-разметкой). Returns: str: Чистый текст без HTML-разметки, спецсимволов и нормализованный. Example: >> safe_html_special_symbols('
Привет мир!
') 'Привет мир!' >> safe_html_special_symbols('Текст с\\u00a0неразрывным и \\u202Fтонким пробелом') 'Текст с неразрывным и тонким пробелом' """ if not s: return "" try: soup = BeautifulSoup(s, "html.parser") except Exception as e: logger.warning(f"BeautifulSoup parse error, using raw string: {e}") return str(s) # Скрипты и стили в чистый текст не нужны — выкидываем их целиком. for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]): tag.decompose() result = soup.get_text() result = unescape(result) # Нормализуем: удаляем невидимые символы, все типы пробелов, дубли и края return normalize_string(result) def make_slug(slug_it: str, max_length: int | None = None, slug_default: str = "content") -> str: """Готовит чистый slug из HTML/Unicode текста (включая русский текст). Преобразует текст в URL-friendly slug: - Удаляет HTML-теги - Транслитерирует русский текст - Удаляет спецсимволы - Обрезает до max_length - Гарантирует уникальность (fallback на случайное значение) Args: slug_it: Строка для преобразования в slug. max_length: Максимальная длина slug (по умолчанию из settings). slug_default: Значение по умолчанию, если slug пустой после обработки. Returns: str: Чистый slug, готовый для использования в URL. Example: >> make_slug('The Beatles') 'the-beatles' >> make_slug('Какой-то текст') 'kakoj-to-tekst' """ if not slug_it: return f"{slug_default}-{random.randint(1, 4095):03x}" max_length = max_length or SLUG_MAX_LENGTH # Вычисляем минимальную длину fallback'а: "slug_default-xyz" min_fallback_length = len(slug_default) + 1 + 3 # "-" и 3 hex-символа # Очищаем текст от HTML и спецсимволов clean_text = safe_html_special_symbols(slug_it).lower() # Транслитерируем и создаем slug (pytils подходит для русского) slug = pytils.translit.slugify(clean_text) # Нормализуем множественные дефисы, удаляем дефисы в начале/конце slug = re.sub(pattern=r"-+", repl="-", string=slug).strip("-") # Обрезаем излишнее (но только если это не нарушит fallback) # Если max_length недостаточен для slug_default, не обрезаем if max_length >= min_fallback_length: slug = slug[:max_length] # Если все еще пусто — генерируем 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: Инициализируем переменную для отслеживания минимальной длины синонимов # Используется для адаптивной фильтрации при PARTIAL_MATCH на очень коротких названиях # (например, "B'Z", "XL", которые после супер-нормализации становятся еще короче). # Начинаем с MIN_SYNONYM_WORD_LENGTH, будет переопределяться по необходимости. effective_min_word_len = MIN_SYNONYM_WORD_LENGTH # ПОДГОТОВКА 2: Получаем базовый 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? # Используем RawSQL с LOWER() для case-insensitive сравнения нормализованных значений exact_matches = records_to_check.annotate( has_exact_match=RawSQL( f"LOWER({main_field_name}) = %s", (normalized_main_value,) ) ).filter(has_exact_match=True) if exact_matches.exists(): duplicates_found.update({ VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE, VALIDATE_KEY__VALUE: exact_matches, }) return duplicates_found # ПРОВЕРКА 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 полей # Строим RawSQL запрос для поиска в JSON массиве синонимов # json_each распарсивает массив и ищет совпадение со значением # LOWER() используется т.к. в БД могут быть строки в любом регистре, но нормализованное значение в нижнем synonym_matches = records_to_check.annotate( has_synonym=RawSQL( f""" EXISTS ( SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE LOWER(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 # Подготавливаем единый список всех значений для анализа: # основное поле + все синонимы из метаданных # Этот список переиспользуется на этапах 3 и 4, избегая дублирования логики raw_values_to_analyze = [main_field_value] if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list): raw_values_to_analyze.extend(metadata_dict[KEY_SYNONYM]) # ПРОВЕРКА 3: EXACT_SYNONYM_MATCH (точное совпадение синонимов текущей записи с синонимами других) # Ищем: есть ли синонимы из текущей записи в синонимах других записей? # Пользователь мог отредактировать список синонимов в форме, и мы не хотим дубликатов среди синонимов. # Например, если текущая запись имеет синонимы=['Polydor Records', 'Vertigo France'], # а запись A имеет синонимы=['Vertigo France', 'Swirl'], это совпадение! # Пользователь подтверждает - удалим конфликтующие синонимы из других записей. if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list): # Собираем все синонимы текущей записи с нормализацией current_synonyms = [ normalize_string(syn) for syn in metadata_dict[KEY_SYNONYM] ] # Удаляем дубликаты синонимов, сохраняя порядок # (может быть пользователь вручную добавил одно и то же несколько раз) current_synonyms = list(dict.fromkeys(current_synonyms)) # Определяем минимальную длину синонимов из текущей записи # (для адаптивной фильтрации при PARTIAL_MATCH на очень коротких названиях) # Учитываем как синонимы, так и основное значение поля (normalized_main_value) # которое может быть коротким и уже совпадало на этапе 2 all_values_to_check = current_synonyms + [normalized_main_value] min_syn_len = min(len(val) for val in all_values_to_check if val) effective_min_word_len = min(effective_min_word_len, min_syn_len) # Если есть синонимы для проверки if current_synonyms: # Строим RawSQL условие для поиска любого из наших синонимов в JSON массиве других записей # Используем один аннотированный queryset со всеми условиями OR для поиска всех конфликтов сразу # LOWER() используется т.к. в БД могут быть строки в любом регистре, но нормализованные значения в нижнем synonym_query_parts = [] query_params = [] for normalized_synonym in current_synonyms: synonym_query_parts.append( f"EXISTS (SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE LOWER(json_each.value) = %s)" ) query_params.append(normalized_synonym) # Объединяем все условия через OR - это вернет записи, у которых есть хотя бы один из наших синонимов synonym_in_others = records_to_check.annotate( has_any_synonym=RawSQL( " OR ".join(synonym_query_parts), query_params ) ).filter(has_any_synonym=True).distinct() # Если найдены записи с совпадающими синонимами - возвращаем их все в одном queryset if synonym_in_others.exists(): duplicates_found.update({ VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.EXACT_SYNONYM_MATCH, VALIDATE_KEY__VALUE: synonym_in_others, }) return duplicates_found # ПРОВЕРКА 4: PARTIAL_MATCH (частичное совпадение слов текущей записи с другими) # Ищем: есть ли значимые слова (> MIN_SYNONYM_WORD_LENGTH) из текущей записи и синонимов # в основных полях и синонимах других записей? # Пример: "The Beatles" содержит слово "beatles" (5 букв > 3), # "Beatles, The" тоже содержит "beatles", это схожесть (предупреждение, не блокировка) # # Собираем все слова из main_field_value и синонимов текущей записи all_current_words = set() # Переиспользуем raw_values_to_analyze (основное поле + синонимы из метаданных), # которое уже подготовили на этапе 3, чтобы избежать дублирования логики. # Обрабатываем каждое значение: супер-нормализуем и собираем слова for value in raw_values_to_analyze: # Используем супер-нормализатор для удаления пунктуации и спецсимволов normalized_value = super_normalize_string(value) value_words = re.split(r'\s+', normalized_value) for word in value_words: # Собираем ВСЕ слова (даже очень короткие) для отслеживания if word: # Только непустые all_current_words.add(word) # Отслеживаем минимальную длину встреченных слов (независимо от MIN_SYNONYM_WORD_LENGTH) effective_min_word_len = min(effective_min_word_len, len(word)) # Если собрали значимые слова - ищем их в других записях if all_current_words: # Строим RawSQL условие для поиска любого из наших слов в main_field_name других записей # или в синонимах других записей (как в main_field_name, так и в JSON) word_search_conditions = [] word_search_params = [] # Условия поиска в основном поле (case-insensitive) for word in all_current_words: # Фильтруем слова по эффективной минимальной длине (может быть меньше MIN_SYNONYM_WORD_LENGTH # если встречались короткие синонимы, например, "B'Z" → "B Z") if len(word) >= effective_min_word_len: # Ищем слово как подстроку в основном поле (окружено пунктуацией/пробелами или в начале/конце) # Используем LIKE с wildcard word_search_conditions.append( f"LOWER({main_field_name}) LIKE %s" ) word_search_params.append(f"%{word}%") # Условия поиска в синонимах (в JSON массиве) for word in all_current_words: # Фильтруем слова по эффективной минимальной длине if len(word) >= effective_min_word_len: word_search_conditions.append( f"""EXISTS ( SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}') WHERE LOWER(json_each.value) LIKE %s )""" ) word_search_params.append(f"%{word}%") # Объединяем условия через OR - ищем хотя бы одно совпадение по словам partial_matches = records_to_check.annotate( has_partial_match=RawSQL( " OR ".join(word_search_conditions), word_search_params ) ).filter(has_partial_match=True).distinct() # Если найдены записи с частичным совпадением - возвращаем их if partial_matches.exists(): # Определяем тип совпадения: если были найдены очень короткие слова (< MIN_SYNONYM_WORD_LENGTH), # помечаем это как PARTIAL_MATCH__RISK_SHORT_WORDS для специального обращения в админке match_type = ValidateMatchType.PARTIAL_MATCH if effective_min_word_len < MIN_SYNONYM_WORD_LENGTH: match_type = ValidateMatchType.PARTIAL_MATCH__RISK_SHORT_WORDS duplicates_found.update({ VALIDATE_KEY__MATCH_TYPE: match_type, VALIDATE_KEY__VALUE: partial_matches, }) return duplicates_found # Когда все проверки прошли -- возвращаем пустой словарь return duplicates_found def remove_conflicting_synonyms_from_duplicates( duplicates_queryset: QuerySet, metadata_field_name: str, synonyms_to_remove: list[str], ) -> None: """ Удаляет из метаданных (поле с именем metadata_field_name) записей (QuerySet) синонимы, которые совпадают со списком (synonyms_to_remove). Универсальный хелпер для очистки синонимов при подтверждении пользователем обхода валидации. Args: duplicates_queryset: QuerySet записей, где нужно удалить синонимы metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata' и т.д.) synonyms_to_remove: Список синонимов для удаления (будут нормализованы перед сравнением) Пример использования: # Удаляем основное поле текущей записи из синонимов других (FIND_IN_SYNONYM) remove_conflicting_synonyms_from_duplicates( duplicates_queryset, 'j_label_metadata', [main_field_value], ) # Удаляем все синонимы текущей записи из синонимов других (EXACT_SYNONYM_MATCH) remove_conflicting_synonyms_from_duplicates( duplicates_queryset, 'j_label_metadata', metadata_dict.get(KEY_SYNONYM) or [], ) """ # Нормализуем синонимы для удаления один раз normalized_to_remove = [normalize_string(syn) for syn in synonyms_to_remove] for duplicate_record in duplicates_queryset: # Получаем текущие метаданные записи dup_metadata = getattr(duplicate_record, metadata_field_name) or {} # Если в метаданных есть синонимы, удаляем те, которые совпадают с нашим списком if KEY_SYNONYM in dup_metadata and isinstance(dup_metadata[KEY_SYNONYM], list): # Удаляем синонимы, которые совпадают с переданным списком dup_metadata[KEY_SYNONYM] = [ syn for syn in dup_metadata[KEY_SYNONYM] if normalize_string(syn) not in normalized_to_remove ] # Сохраняем обновленные метаданные setattr(duplicate_record, metadata_field_name, dup_metadata) # Сохраняем запись (обновляем только поле с метаданными) duplicate_record.save(update_fields=[metadata_field_name]) def build_duplicates_report(duplicates_queryset, search_words, metadata_field_name, main_field_name): """ Формирует красивый отчет о найденных совпадениях слов в дубликатах для админки. Для каждого поискового слова показывает в каких записях и в каких полях/синонимах оно найдено, с выделением найденного слова в оригинальном тексте. Args: duplicates_queryset: QuerySet записей, в которых нашли совпадения search_words: Set/List слов для поиска (нормализованные слова) metadata_field_name: Имя поля метаданных ('j_label_metadata' и т.д.) main_field_name: Имя основного поля ('s_label' и т.д.) Returns: str: HTML строка с отчетом о совпадениях, например: "beatles: встречается в #123 'The Beatles' → основное поле: 'The Beatles' atlantic: встречается в #456 'Atlantic Records' → основное поле: 'Atlantic Records'" """ report_lines = [] # Для каждого поискового слова for search_word in sorted(search_words): matches_for_word = [] # Проходим по всем найденным дубликатам for dup in duplicates_queryset: # Получаем основное поле этой записи main_field_value = getattr(dup, main_field_name, '') # Получаем метаданные и синонимы dup_metadata = getattr(dup, metadata_field_name) or {} dup_synonyms = dup_metadata.get(KEY_SYNONYM) or [] # Список всех значений для поиска (основное поле + синонимы) all_values = [main_field_value] + dup_synonyms # Ищем слово в основном поле и синонимах (case-insensitive) found_locations = [] # Проверяем основное поле if main_field_value: # Используем регулярное выражение для case-insensitive поиска и выделения highlighted_main = re.sub( rf'\b{re.escape(search_word)}\b', f'{search_word}', main_field_value, flags=re.IGNORECASE ) if highlighted_main != main_field_value: # Совпадение найдено found_locations.append(f"основное поле: "{highlighted_main}"") # Проверяем синонимы for synonym in dup_synonyms: if synonym: # Ищем слово в синониме (case-insensitive) highlighted_syn = re.sub( rf'\b{re.escape(search_word)}\b', f'{search_word}', synonym, flags=re.IGNORECASE ) if highlighted_syn != synonym: # Совпадение найдено found_locations.append(f"синоним: "{highlighted_syn}"") # Если слово найдено в этой записи - добавляем в отчет if found_locations: rel_url = f"../{dup.pk}/change/" # Относительная ссылка на запись dup_display_name = getattr(dup, main_field_name, '?') locations_html = ", ".join(found_locations) matches_for_word.append( f"#{dup.pk} "{dup_display_name}" → {locations_html}" ) # Если слово найдено хотя бы в одной записи - добавляем строку в отчет if matches_for_word: matches_html = "