mod: валидатор форм, парсера и моделей (13) улучшен отчет валидатора для случаев "схожести текущей записи и синонимов с синонимами других записей"

This commit is contained in:
2026-06-29 13:30:37 +03:00
parent 01710200b2
commit 7b623e975d
+137 -17
View File
@@ -550,6 +550,91 @@ def remove_conflicting_synonyms_from_duplicates(
duplicate_record.save(update_fields=[metadata_field_name]) 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 <u>Beatles</u>'
atlantic: встречается в #456 'Atlantic Records' → основное поле: '<u>Atlantic</u> 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'<u>{search_word}</u>',
main_field_value,
flags=re.IGNORECASE
)
if highlighted_main != main_field_value: # Совпадение найдено
found_locations.append(f"основное поле: &quot;{highlighted_main}&quot;")
# Проверяем синонимы
for synonym in dup_synonyms:
if synonym:
# Ищем слово в синониме (case-insensitive)
highlighted_syn = re.sub(
rf'\b{re.escape(search_word)}\b',
f'<u>{search_word}</u>',
synonym,
flags=re.IGNORECASE
)
if highlighted_syn != synonym: # Совпадение найдено
found_locations.append(f"синоним: &quot;{highlighted_syn}&quot;")
# Если слово найдено в этой записи - добавляем в отчет
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"<a href='{rel_url}'>#{dup.pk} &quot;{dup_display_name}&quot;</a> → {locations_html}"
)
# Если слово найдено хотя бы в одной записи - добавляем строку в отчет
if matches_for_word:
matches_html = "<br/> ".join(matches_for_word)
report_lines.append(f"<b>{search_word}:</b> встречается в<br/> {matches_html}")
# Объединяем все строки в один HTML отчет
if report_lines:
return "<br/>".join(report_lines)
else:
return "Совпадения не найдены"
def validate_entity_for_admin_form(form_instance, cleaned_data, def validate_entity_for_admin_form(form_instance, cleaned_data,
main_field_name='s_label', main_field_name='s_label',
metadata_field_name='j_label_metadata', metadata_field_name='j_label_metadata',
@@ -785,17 +870,35 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
else: else:
# РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА # РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА
# Показываем пользователю информативное предупреждение о схожести # Показываем пользователю информативное предупреждение о схожести
for dup in duplicates_queryset: # Собираем поисковые слова для отчета (используются для выделения в отчете)
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/" search_words = set()
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>") # Обрабатываем main_field_value - делим его на слова и собираем их
dup_list = ", ".join(dup_links) main_value_normalized = super_normalize_string(cleaned_data.get(main_field_name, ''))
search_words.update(re.split(r'\s+', main_value_normalized))
# Обрабатываем синонимы из метаданных
metadata_synonyms = metadata_dict.get(KEY_SYNONYM) or []
for synonym in metadata_synonyms:
syn_normalized = super_normalize_string(synonym)
search_words.update(re.split(r'\s+', syn_normalized))
# Удаляем пустые строки из набора слов
search_words.discard('')
# Формируем красивый отчет о совпадениях с выделением слов
report_html = build_duplicates_report(
duplicates_queryset,
search_words,
metadata_field_name,
main_field_name
)
# Кнопка для игнорирования предупреждения (без критичности) # Кнопка для игнорирования предупреждения (без критичности)
raise ValidationError( raise ValidationError(
mark_safe( mark_safe(
f"ИНФОРМАЦИЯ: Найдены похожие записи со схожими словами! " f"ИНФОРМАЦИЯ: Найдены похожие записи со схожими словами!<br/>"
f"Проверьте: {dup_list} " f"{report_html}<br/>"
f"Это просто информация, не ошибка. Но неточности на сайте могут рассмешить" f"Это просто информация, не ошибка. Но неточности на сайте могут рассмешить"
f" (или огорчить) пользователей." f" (или огорчить) пользователей."
f"<div class=\"confirmation-button-container\">" f"<div class=\"confirmation-button-container\">"
@@ -816,24 +919,41 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
# Показываем ещё более серьезное предупреждение о необходимости проверки # Показываем ещё более серьезное предупреждение о необходимости проверки
if request and request.GET.get('ignore_validate') == '1': if request and request.GET.get('ignore_validate') == '1':
# РЕЖИМ: ОБХОД ВАЛИДАЦИИ (пользователь подтвердил что ознакомлен с рисками) # РЕЖИМ: ОБХОД ВАЛИДАЦИИ (пользователь подтвердил то ознакомлен с рисками)
# Тихо сохраняем запись, ничего не меняя в других записях # Тихо сохраняем запись, ничего не меняя в других записях
return return
else: else:
# РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА # РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА
# Показываем пользователю СЕРЬЕЗНОЕ предупреждение о высоком риске ошибки # Собираем поисковые слова для отчета (используются для выделения в отчете)
for dup in duplicates_queryset: search_words = set()
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, '?') # Обрабатываем main_field_value - делим его на слова и собираем их
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>") main_value_normalized = super_normalize_string(cleaned_data.get(main_field_name, ''))
dup_list = ", ".join(dup_links) search_words.update(re.split(r'\s+', main_value_normalized))
# Обрабатываем синонимы из метаданных
metadata_synonyms = metadata_dict.get(KEY_SYNONYM) or []
for synonym in metadata_synonyms:
syn_normalized = super_normalize_string(synonym)
search_words.update(re.split(r'\s+', syn_normalized))
# Удаляем пустые строки из набора слов
search_words.discard('')
# Формируем красивый отчет о совпадениях с выделением слов
report_html = build_duplicates_report(
duplicates_queryset,
search_words,
metadata_field_name,
main_field_name
)
# Кнопка для подтверждения с указанием на риск # Кнопка для подтверждения с указанием на риск
raise ValidationError( raise ValidationError(
mark_safe( mark_safe(
f"ВНИМАНИЕ: ВЫСОКИЙ РИСК ОШИБКИ! Найдены похожие записи с ОЧЕНЬ КОРОТКИМИ словами! " f"ВНИМАНИЕ: ВЫСОКИЙ РИСК ОШИБКИ! Найдены похожие записи с ОЧЕНЬ КОРОТКИМИ словами!<br/>"
f"Проверьте ОЧЕНЬ ВНИМАТЕЛЬНО: {dup_list} " f"{report_html}<br/>"
f"Совпадения могут быть двухбуквенными и даже однобуквенными словами (например:" f"Совпадения могут быть двухбуквенными и даже однобуквенными словами (например:"
f" \"B'Z\"\"B Z\" или \"R&B\"\"R B\" ). ОЧЕНЬ ВЕЛИК РИСК ложных срабатываний!" f" \"B'Z\"\"B Z\" или \"R&B\"\"R B\" ). ОЧЕНЬ ВЕЛИК РИСК ложных срабатываний!"
f"<div class=\"confirmation-button-container\">" f"<div class=\"confirmation-button-container\">"
@@ -849,7 +969,7 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
case _: case _:
# Неизвестный или не обработанный тип совпадения # Неизвестный или не обработанный тип совпадения
# В будущем сюда можно добавить логирование неожиданных типов # В будущем сюда можно добавить логирование неожиданны типов
pass pass
return return