mod: валидатор форм, парсера и моделей (05) избежания дублей в синонимах других записей.
This commit is contained in:
@@ -430,14 +430,19 @@ class LabelAdminForm(forms.ModelForm):
|
|||||||
Кастомная форма для админки лейблов (Label).
|
Кастомная форма для админки лейблов (Label).
|
||||||
Добавляет виджеты CodeMirror для текстовых полей
|
Добавляет виджеты CodeMirror для текстовых полей
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TbLabel
|
model = TbLabel
|
||||||
fields = ('s_label', 'k_label_to_article', 'j_label_metadata',)
|
fields = ('s_label', 'k_label_to_article', 'j_label_metadata',)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
При инициализации формы подгружаем
|
При инициализации формы подгружаем CodeMirror.
|
||||||
|
Получаем request из kwargs, переданных из get_form_kwargs в AdminClass.
|
||||||
"""
|
"""
|
||||||
|
# Извлекаем request из kwargs если он есть
|
||||||
|
self.request = kwargs.pop('request', None)
|
||||||
|
|
||||||
# Атрибуты для активации CodeMirror редактора
|
# Атрибуты для активации CodeMirror редактора
|
||||||
codemirror_attrs = {
|
codemirror_attrs = {
|
||||||
'data-codemirror-editor': '1',
|
'data-codemirror-editor': '1',
|
||||||
@@ -446,6 +451,7 @@ class LabelAdminForm(forms.ModelForm):
|
|||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# Активируем CodeMirror и устанавливаем классы для реальных полей
|
# Активируем CodeMirror и устанавливаем классы для реальных полей
|
||||||
self.fields['s_label'].widget = Textarea(attrs={
|
self.fields['s_label'].widget = Textarea(attrs={
|
||||||
**codemirror_attrs,
|
**codemirror_attrs,
|
||||||
@@ -458,19 +464,23 @@ class LabelAdminForm(forms.ModelForm):
|
|||||||
'data-language': 'json',
|
'data-language': 'json',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label
|
Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_label.
|
||||||
|
Используем GET параметр ignore_validate для пропуска валидации при переотправке.
|
||||||
"""
|
"""
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
# Используем универсальный хелпер для проверки дубликатов
|
# Используем универсальный хелпер для проверки дубликатов
|
||||||
# Модель берется автоматически из self.Meta.model
|
# Модель берется автоматически из self.Meta.model
|
||||||
|
# Передаем request для проверки GET параметра ignore_validate
|
||||||
validate_entity_for_admin_form(
|
validate_entity_for_admin_form(
|
||||||
self,
|
self,
|
||||||
cleaned_data,
|
cleaned_data,
|
||||||
main_field_name='s_label',
|
main_field_name='s_label',
|
||||||
metadata_field_name='j_label_metadata'
|
metadata_field_name='j_label_metadata',
|
||||||
|
request=self.request,
|
||||||
)
|
)
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
@@ -489,6 +499,7 @@ class LabelAdmin(admin.ModelAdmin):
|
|||||||
'codemirror/editor.js', # Основной CodeMirror
|
'codemirror/editor.js', # Основной CodeMirror
|
||||||
'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной
|
'codemirror/codemirror-patch.js', # Патч для управления высотой/шириной
|
||||||
)
|
)
|
||||||
|
|
||||||
list_display = ('id', 's_label', 't_label_created')
|
list_display = ('id', 's_label', 't_label_created')
|
||||||
list_display_links = ('id', 's_label',)
|
list_display_links = ('id', 's_label',)
|
||||||
search_fields = ('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
|
# АДМИН-ПАНЕЛЬ ДЛЯ ПРОДАВЦА/SELLER
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -242,26 +242,55 @@ def validate_for_duplicates(
|
|||||||
|
|
||||||
duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__}
|
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 (точное совпадение основного поля)
|
# ПРОВЕРКА 1: EXACT MATCH (точное совпадение основного поля)
|
||||||
# Ищем: есть ли другая запись с точно таким же main_field_value?
|
# Ищем: есть ли другая запись с точно таким же main_field_value?
|
||||||
filter_kwargs = {f"{main_field_name}__exact": normalized_main_value}
|
filter_kwargs = {f"{main_field_name}__exact": normalized_main_value}
|
||||||
exact_matches = model_class.objects.filter(**filter_kwargs)
|
exact_matches = records_to_check.filter(**filter_kwargs)
|
||||||
if instance_pk is not None:
|
|
||||||
# При редактировании, чтобы не найти "самого себя" как дубликат, исключаем текущую запись из поиска
|
|
||||||
exact_matches = exact_matches.exclude(pk=instance_pk)
|
|
||||||
if exact_matches.exists():
|
if exact_matches.exists():
|
||||||
duplicates_found.update({
|
duplicates_found.update({
|
||||||
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
|
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
|
||||||
VALIDATE_KEY__VALUE: exact_matches,
|
VALIDATE_KEY__VALUE: exact_matches,
|
||||||
})
|
})
|
||||||
return duplicates_found
|
return duplicates_found
|
||||||
# for other_record in exact_matches:
|
|
||||||
# other_main_value = str(getattr(other_record, main_field_name, ""))
|
# ПРОВЕРКА 2: SYNONYM MATCH (совпадение с синонимами в метаданных других записей)
|
||||||
# duplicates_found[VALIDATE_KEY__VALUE].append({
|
# Ищем: есть ли текущее значение main_field в синонимах других записей?
|
||||||
# MATCH__KEY_PK: other_record.pk,
|
# Например, если у записи A синонимы=['Sony Music', 'SME Records'],
|
||||||
# 's_label': other_main_value,
|
# а мы добавляем запись B с основным полем 'Sony Music', это совпадение!
|
||||||
# 'matched_value': normalized_main_value,
|
|
||||||
# })
|
# Используем 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
|
return duplicates_found
|
||||||
@@ -269,7 +298,8 @@ def validate_for_duplicates(
|
|||||||
|
|
||||||
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',
|
||||||
|
request=None):
|
||||||
"""
|
"""
|
||||||
Универсальный валидатор для админских форм.
|
Универсальный валидатор для админских форм.
|
||||||
|
|
||||||
@@ -283,9 +313,10 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
|
|||||||
cleaned_data: Очищенные данные формы
|
cleaned_data: Очищенные данные формы
|
||||||
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name')
|
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name')
|
||||||
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata')
|
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata')
|
||||||
|
request: HTTP request объект (опционально, используется для проверки GET параметра ignore_validate)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: Если найдены совпадения (дубликаты)
|
ValidationError: Если найдены совпадения (дубликаты) и GET параметр не установлен
|
||||||
|
|
||||||
Пример использования в LabelAdminForm:
|
Пример использования в LabelAdminForm:
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -294,12 +325,18 @@ def validate_entity_for_admin_form(form_instance, cleaned_data,
|
|||||||
self,
|
self,
|
||||||
cleaned_data,
|
cleaned_data,
|
||||||
main_field_name='s_label',
|
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
|
return cleaned_data
|
||||||
"""
|
"""
|
||||||
from django.utils.html import mark_safe
|
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
|
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]
|
match_type = result[VALIDATE_KEY__MATCH_TYPE]
|
||||||
duplicates_queryset = result[VALIDATE_KEY__VALUE]
|
duplicates_queryset = result[VALIDATE_KEY__VALUE]
|
||||||
|
|
||||||
|
# В dup_links формируем ссылки на найденные дубликаты для быстрого перехода в админке
|
||||||
|
dup_links = []
|
||||||
|
|
||||||
# Используем match-case для удобной обработки разных типов совпадений
|
# Используем match-case для удобной обработки разных типов совпадений
|
||||||
# С Enum вместо магических чисел код становится самодокументируемым
|
# С Enum вместо магических чисел код становится самодокументируемым
|
||||||
# В будущем легко добавить новые типы: ValidateMatchType.PARTIAL_MATCH = 2 и т.д.
|
# В будущем легко добавить новые типы: ValidateMatchType.PARTIAL_MATCH = 2 и т.д.
|
||||||
match match_type:
|
match match_type:
|
||||||
case ValidateMatchType.IS_DUPLICATE:
|
case ValidateMatchType.IS_DUPLICATE:
|
||||||
# ОБРАБОТКА ТОЧНЫХ ДУБЛИКАТОВ
|
# ОБРАБОТКА ТОЧНЫХ ДУБЛИКАТОВ
|
||||||
# Строим ссылки на найденные дубликаты для быстрого перехода в админке
|
|
||||||
dup_links = []
|
|
||||||
|
|
||||||
for dup in duplicates_queryset:
|
for dup in duplicates_queryset:
|
||||||
# Относительная ссылка зависит от режима админки:
|
# Относительная ссылка зависит от режима админки:
|
||||||
# При создании: /admin/app/model/add/ → ../456/change/
|
# При создании: /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)
|
dup_list = ", ".join(dup_links)
|
||||||
|
|
||||||
# Выбрасываем ValidationError с HTML ссылками на дубликаты
|
# Для случая IS_DUPLICATE отключена проверка force_ignore_validate, т.к. это критическая ситуация
|
||||||
|
# и проверяемом поле часто unique=True на уровне модели.
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
mark_safe(
|
mark_safe(
|
||||||
f"ОШИБКА: Найдено совпадение! "
|
f"ОШИБКА: Найдено ПОЛНОЕ совпадение! "
|
||||||
f"Отредактируйте {dup_list} "
|
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"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
|
||||||
|
dup_list = ", ".join(dup_links)
|
||||||
|
|
||||||
|
# Кнопка подтверждения создания несмотря на синонимы
|
||||||
|
# При клике меняет value всех submit-кнопок на 'ignore_validate' и отправляет форму
|
||||||
|
# Если пользователь потом меняет данные - вотчер вернет оригинальные значения
|
||||||
|
confirmation_button = '''
|
||||||
|
<br><br>
|
||||||
|
<button type="button"
|
||||||
|
onclick="
|
||||||
|
// Меняем value у всех submit-кнопок на 'ignore_validate'
|
||||||
|
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
|
||||||
|
btn.value = 'ignore_validate';
|
||||||
|
});
|
||||||
|
// Отправляем форму через первую найденную submit-кнопку
|
||||||
|
document.querySelector('input[type=submit]').click();
|
||||||
|
"
|
||||||
|
style="padding: 10px 15px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||||
|
Я уверен, создать несмотря на синонимы
|
||||||
|
</button>
|
||||||
|
<em style="display: block; margin-top: 8px; color: #666; font-size: 12px;">
|
||||||
|
Форма будет переотправлена без проверки синонимов
|
||||||
|
</em>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Вотчер: если пользователь меняет данные в форме, отменяем флаг ignore_validate
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Сохраняем оригинальные значения submit-кнопок
|
||||||
|
let originalValues = {};
|
||||||
|
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
|
||||||
|
originalValues[btn.name] = btn.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отслеживаем изменения всех input/textarea полей в форме
|
||||||
|
let formInputs = document.querySelectorAll('input[type!=submit], textarea, select');
|
||||||
|
formInputs.forEach(function(input) {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
// Если пользователь изменил данные, восстанавливаем оригинальные значения кнопок
|
||||||
|
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
|
||||||
|
btn.value = originalValues[btn.name];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
'''
|
||||||
|
|
||||||
|
raise ValidationError(
|
||||||
|
mark_safe(
|
||||||
|
f"ВНИМАНИЕ: Найдено совпадение в синонимах! "
|
||||||
|
f"Проверьте {dup_list} "
|
||||||
f"или используйте синонимы из найденной записи."
|
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):
|
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
|
||||||
metadata_dict[KEY_SYNONYM] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM]))
|
metadata_dict[KEY_SYNONYM] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM]))
|
||||||
|
|
||||||
|
# ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ =====
|
||||||
|
# Для JSONField нужно явно сообщить что мы изменили содержимое
|
||||||
|
# иначе Django может не сохранить изменения
|
||||||
|
setattr(instance, metadata_field_name, metadata_dict)
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,6 @@ VALIDATE_KEY__VALUE = 'MATCH_VALUE'
|
|||||||
class ValidateMatchType(IntEnum):
|
class ValidateMatchType(IntEnum):
|
||||||
"""Типы совпадений при поиске дубликатов."""
|
"""Типы совпадений при поиске дубликатов."""
|
||||||
IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.)
|
IS_DUPLICATE = 1 # Точное совпадение основного поля (s_label, s_artist и т.д.)
|
||||||
# PARTIAL_MATCH = 2 # Частичное совпадение
|
FIND_IN_SYNONYM = 2 # Частичное совпадение
|
||||||
# SYNONYM_MATCH = 3 # Совпадение по синониму
|
# SYNONYM_MATCH = 3 # Совпадение по синониму
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user