mod: валидатор форм, парсера и моделей (09) уделение дублей в синонимах других записей при редактировании через админку

This commit is contained in:
2026-06-23 15:47:46 +03:00
parent 562b58be47
commit 48df2fb32b
2 changed files with 155 additions and 131 deletions

View File

@@ -332,11 +332,6 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
""" """
from django.utils.html import mark_safe from django.utils.html import mark_safe
# ПЕРЕД ВАЛИДАЦИЕЙ: проверяем GET параметр ignore_validate
# Если пользователь нажал красную кнопку, addGetParam добавит GET параметр к URL
if request and request.GET.get('ignore_validate') == '1':
return
# Получаем класс модели из метаинформации формы # Получаем класс модели из метаинформации формы
model_class = form_instance.Meta.model model_class = form_instance.Meta.model
@@ -348,6 +343,9 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
if not main_field_value: if not main_field_value:
return return
# Нормализуем основное значение для сравнения (как в validate_for_duplicates)
normalized_main_value = normalize_string(main_field_value)
# Вызываем основной валидатор дубликатов # Вызываем основной валидатор дубликатов
result = validate_for_duplicates( result = validate_for_duplicates(
model_class=model_class, model_class=model_class,
@@ -384,8 +382,8 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
# Объединяем все найденные дубликаты в один список # Объединяем все найденные дубликаты в один список
dup_list = ", ".join(dup_links) dup_list = ", ".join(dup_links)
# Для случая IS_DUPLICATE отключена проверка force_ignore_validate, т.к. это критическая ситуация # Для случая IS_DUPLICATE всегда выбрасываем ошибку, т.к. это критическая ситуация
# и проверяемом поле часто unique=True на уровне модели. # и поле часто имеет unique=True на уровне модели.
raise ValidationError( raise ValidationError(
mark_safe( mark_safe(
f"ОШИБКА: Найдено ПОЛНОЕ совпадение! " f"ОШИБКА: Найдено ПОЛНОЕ совпадение! "
@@ -395,37 +393,63 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
case ValidateMatchType.FIND_IN_SYNONYM: case ValidateMatchType.FIND_IN_SYNONYM:
# ОБРАБОТКА СОВПАДЕНИЙ В СИНОНИМАХ # ОБРАБОТКА СОВПАДЕНИЙ В СИНОНИМАХ
for dup in duplicates_queryset: # Проверяем: это запрос с подтверждением (ignore_validate=1) или первоначальная проверка?
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/" if request and request.GET.get('ignore_validate') == '1':
dup_value = getattr(dup, main_field_name, '?') # РЕЖИМ: ОБХОД ВАЛИДАЦИИ (пользователь нажал красную кнопку и подтвердил "Я проверил и уверен!")
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>") # Тихо удаляем найденные совпадения из синонимов других записей
dup_list = ", ".join(dup_links) for duplicate_record in duplicates_queryset:
# Получаем текущие метаданные записи
dup_metadata = getattr(duplicate_record, metadata_field_name) or {}
# Кнопка подтверждения создания несмотря на синонимы # Если в метаданных есть синонимы, удаляем из них текущее значение
# При клике вызывает функцию markSubmitButtonsToIgnoreValidation() if KEY_SYNONYM in dup_metadata and isinstance(dup_metadata[KEY_SYNONYM], list):
# которая добавляет класс force-ignore-validation ко всем submit-кнопкам. # Удаляем нормализованное значение из списка синонимов
# Вотчер видит этот класс и добавляет onclick обработчик к кнопкам dup_metadata[KEY_SYNONYM] = [
# для добавления GET параметра ignore_validate=1 перед отправкой формы. syn for syn in dup_metadata[KEY_SYNONYM]
# Весь JS код находится в form-field-watcher.js для чистоты и переиспользования. if normalize_string(syn) != normalized_main_value
confirmation_button = ''' ]
<div class="confirmation-button-container"> # Сохраняем обновленные метаданные
<button type="button" onclick="markSubmitButtonsToIgnoreValidation();"> setattr(duplicate_record, metadata_field_name, dup_metadata)
# Сохраняем запись (обновляем только поле с метаданными)
duplicate_record.save(update_fields=[metadata_field_name])
# Выходим без ошибки в админку, т.к. пользователь "проверил и уверен!"
# Синонимы из удалены, запись сохранится нормально
return
else:
# РЕЖИМ: ПЕРВОНАЧАЛЬНАЯ ПРОВЕРКА
# Показываем пользователю красную кнопку подтверждения с информацией о совпадениях
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"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
dup_list = ", ".join(dup_links)
# Кнопка подтверждения создания несмотря на синонимы
# При клике вызывает функцию markSubmitButtonsToIgnoreValidation()
# которая добавляет класс force-ignore-validation ко всем submit-кнопкам.
# Вотчер видит этот класс и добавляет onclick обработчик к кнопкам
# для добавления GET параметра ignore_validate=1 перед отправкой формы.
# Весь JS код находится в form-field-watcher.js для чистоты и переиспользования.
confirmation_button = '''
<div class="confirmation-button-container">
<button type="button" onclick="markSubmitButtonsToIgnoreValidation();">
<big>Я проверил и уверен!</big><br/> <big>Я проверил и уверен!</big><br/>
Сохранить, несмотря на синонимы.<br/> Сохранить, несмотря на синонимы.<br/>
<i>Точно совпадения в синонимах других записей будут удалены.</i> <i>Точные совпадения в синонимах других записей будут удалены.</i>
</button> </button>
<em>Теперь нажмите стандартные кнопки сохранения снизу, чтобы сохранить.</em> <em>Теперь нажмите стандартные кнопки сохранения снизу, чтобы сохранить.</em>
</div> </div>
''' '''
raise ValidationError( raise ValidationError(
mark_safe( mark_safe(
f"ВНИМАНИЕ: Найдено совпадение в синонимах! " f"ВНИМАНИЕ: Найдено совпадение в синонимах! "
f"Проверьте {dup_list} " f"Проверьте {dup_list} "
f"или используйте синонимы из найденной записи." f"или используйте синонимы из найденной записи."
f"{confirmation_button}" f"{confirmation_button}"
)
) )
)
case _: case _:
# Неизвестный или не обработанный тип совпадения # Неизвестный или не обработанный тип совпадения

View File

@@ -18,120 +18,120 @@
// Функция для добавления GET параметра к form action кнопки // Функция для добавления GET параметра к form action кнопки
function addGetParam(button, key, value) { function addGetParam(button, key, value) {
const form = button.form; // Находим форму, в которой лежит кнопка const form = button.form; // Находим форму, в которой лежит кнопка
const baseAction = form.getAttribute('action'); // Получаем текущий action const baseAction = form.getAttribute('action'); // Получаем текущий action
// Удаляем старый параметр, если он есть // Удаляем старый параметр, если он есть
let cleanAction = baseAction.split('?')[0]; let cleanAction = baseAction.split('?')[0];
// Проверяем, есть ли уже в action другие GET-параметры // Проверяем, есть ли уже в action другие GET-параметры
const separator = cleanAction === baseAction ? '?' : '&'; const separator = cleanAction === baseAction ? '?' : '&';
// Динамически прописываем измененный URL в formAction кнопки // Динамически прописываем измененный URL в formAction кнопки
button.formAction = baseAction + separator + key + '=' + value; button.formAction = baseAction + separator + key + '=' + value;
} }
// Функция для добавления класса force-ignore-validation ко всем submit-кнопкам формы // Функция для добавления класса force-ignore-validation ко всем submit-кнопкам формы
// Используется при клике на кнопку "Я проверил и уверен!" // Используется при клике на кнопку "Я проверил и уверен!"
function markSubmitButtonsToIgnoreValidation() { function markSubmitButtonsToIgnoreValidation() {
// Находим все submit-кнопки на странице и добавляем им класс // Находим все submit-кнопки на странице и добавляем им класс
// form-field-watcher.js потом отследит добавление класса через MutationObserver // form-field-watcher.js потом отследит добавление класса через MutationObserver
// и добавит соответствующие onclick обработчики // и добавит соответствующие onclick обработчики
document.querySelectorAll('input[type=submit]').forEach(function(btn) { document.querySelectorAll('input[type=submit]').forEach(function (btn) {
btn.classList.add('force-ignore-validation'); btn.classList.add('force-ignore-validation');
}); });
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// Находим все submit-кнопки администратора // Находим все submit-кнопки администратора
let submitButtons = document.querySelectorAll('input[type=submit]'); let submitButtons = document.querySelectorAll('input[type=submit]');
// Если нет submit-кнопок, выходим (не админская форма) // Если нет submit-кнопок, выходим (не админская форма)
if (submitButtons.length === 0) { if (submitButtons.length === 0) {
return; return;
}
// Находим форму
let form = document.querySelector('form');
if (!form) {
return;
}
// Сохраняем оригинальный action формы
let originalAction = form.getAttribute('action');
// Функция для добавления onclick обработчика
function addOnclickHandler(btn) {
// Проверяем есть ли уже onclick
if (!btn.getAttribute('onclick')) {
btn.setAttribute('onclick', 'if (this.classList.contains("force-ignore-validation")) { ' +
'const form = this.form; ' +
'const baseAction = form.getAttribute("action") || ""; ' +
'let cleanAction = baseAction.split("?")[0]; ' +
'const separator = cleanAction === baseAction ? "?" : "&"; ' +
'form.setAttribute("action", baseAction + separator + "ignore_validate=1"); ' +
'}');
} }
}
// Находим форму // Функция для удаления onclick обработчика
let form = document.querySelector('form'); function removeOnclickHandler(btn) {
if (!form) { btn.removeAttribute('onclick');
return; }
}
// Сохраняем оригинальный action формы // Отслеживаем изменения всех типов полей в форме
let originalAction = form.getAttribute('action'); let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
// Функция для добавления onclick обработчика // Функция которая срабатывает при любом изменении
function addOnclickHandler(btn) { function handleChange() {
// Проверяем есть ли уже onclick // При изменении любого поля:
if (!btn.getAttribute('onclick')) { // 1. Удаляем класс force-ignore-validation (кнопки вернут нормальный цвет)
btn.setAttribute('onclick', 'if (this.classList.contains("force-ignore-validation")) { ' + // 2. Удаляем onclick обработчик
'const form = this.form; ' + // 3. Восстанавливаем оригинальный action формы
'const baseAction = form.getAttribute("action") || ""; ' + submitButtons.forEach(function (btn) {
'let cleanAction = baseAction.split("?")[0]; ' + if (btn.classList.contains('force-ignore-validation')) {
'const separator = cleanAction === baseAction ? "?" : "&"; ' + btn.classList.remove('force-ignore-validation');
'form.setAttribute("action", baseAction + separator + "ignore_validate=1"); ' + removeOnclickHandler(btn);
'}'); }
});
form.setAttribute('action', originalAction);
// Скрываем сообщения об ошибках валидации
let errorNotes = document.querySelectorAll('.errornote, .errorlist');
errorNotes.forEach(function (errorElement) {
errorElement.style.display = 'none';
});
}
formInputs.forEach(function (input) {
// Слушаем оба события: 'change' для обычных input/select
// и 'input' для CodeMirror и других редакторов
input.addEventListener('change', handleChange);
input.addEventListener('input', handleChange);
});
// Мониторим изменение класса force-ignore-validation на кнопках
// Используем MutationObserver для отслеживания добавления/удаления класса
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
let btn = mutation.target;
if (btn.tagName === 'INPUT' && btn.type === 'submit') {
// Если класс добавлен - навешиваем onclick
if (btn.classList.contains('force-ignore-validation')) {
addOnclickHandler(btn);
}
// Если класс удален - удаляем onclick
else if (btn.getAttribute('onclick')) {
removeOnclickHandler(btn);
}
} }
} }
// Функция для удаления onclick обработчика
function removeOnclickHandler(btn) {
btn.removeAttribute('onclick');
}
// Отслеживаем изменения всех типов полей в форме
let formInputs = document.querySelectorAll('input:not([type=submit]), textarea, select, .codemirror, [contenteditable]');
// Функция которая срабатывает при любом изменении
function handleChange() {
// При изменении любого поля:
// 1. Удаляем класс force-ignore-validation (кнопки вернут нормальный цвет)
// 2. Удаляем onclick обработчик
// 3. Восстанавливаем оригинальный action формы
submitButtons.forEach(function(btn) {
if (btn.classList.contains('force-ignore-validation')) {
btn.classList.remove('force-ignore-validation');
removeOnclickHandler(btn);
}
});
form.setAttribute('action', originalAction);
// Скрываем сообщения об ошибках валидации
let errorNotes = document.querySelectorAll('.errornote, .errorlist');
errorNotes.forEach(function(errorElement) {
errorElement.style.display = 'none';
});
}
formInputs.forEach(function(input) {
// Слушаем оба события: 'change' для обычных input/select
// и 'input' для CodeMirror и других редакторов
input.addEventListener('change', handleChange);
input.addEventListener('input', handleChange);
}); });
});
// Мониторим изменение класса force-ignore-validation на кнопках // Настраиваем observer для отслеживания изменения класса
// Используем MutationObserver для отслеживания добавления/удаления класса submitButtons.forEach(function (btn) {
const observer = new MutationObserver(function(mutations) { observer.observe(btn, {attributes: true, attributeFilter: ['class']});
mutations.forEach(function(mutation) { });
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
let btn = mutation.target;
if (btn.tagName === 'INPUT' && btn.type === 'submit') {
// Если класс добавлен - навешиваем onclick
if (btn.classList.contains('force-ignore-validation')) {
addOnclickHandler(btn);
}
// Если класс удален - удаляем onclick
else if (btn.getAttribute('onclick')) {
removeOnclickHandler(btn);
}
}
}
});
});
// Настраиваем observer для отслеживания изменения класса
submitButtons.forEach(function(btn) {
observer.observe(btn, { attributes: true, attributeFilter: ['class'] });
});
}); });