diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index f29fbf4..4136dec 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -4,6 +4,7 @@ from typing import Any from django import forms from django.forms import Textarea +from django.http import HttpRequest from django.contrib import admin from django.utils.html import format_html, mark_safe from easy_thumbnails.files import get_thumbnailer @@ -336,7 +337,13 @@ class ImageAdmin(admin.ModelAdmin): _display_title_text.short_description = 'TITLE из filer' - def save_model(self, request, obj, form, change): + def save_model( + self, + request: HttpRequest, + obj: TbImage, + form: forms.ModelForm, + change: bool, + ) -> None: """ Переопределяем save_model для обновления метаданных filer_image. @@ -448,6 +455,9 @@ class ArtistAdminForm(CodeMirrorFormMixin): """ При инициализации формы подгружаем CodeMirror редактор """ + # Извлекаем request из kwargs если он есть + self.request = kwargs.pop('request', None) + super().__init__(*args, **kwargs) # Конфигурируем поля для CodeMirror @@ -456,8 +466,36 @@ class ArtistAdminForm(CodeMirrorFormMixin): self.setup_codemirror_field('j_artist_metadata', language='json', css_class='codemirror-width-l codemirror-min-height-5') + def clean(self): + """ + Валидируем форму: проверяем на совпадения (дубликаты) основного поля s_artist. + Используем GET параметр ignore_validate для пропуска валидации при переотправке. + """ + # Получаем очищенные данные формы (может быть None, но обычно это dict) + cleaned_data: dict[str, Any] | None = super().clean() + + # Если clean() вернул None, возвращаем пустой dict (для совместимости) + if cleaned_data is None: + cleaned_data = {} + + # После проверки выше, очищены данные гарантированно dict[str, Any] + assert isinstance(cleaned_data, dict), "cleaned_data должен быть словарём" + + # Используем универсальный хелпер для проверки дубликатов + # Модель берется автоматически из self.Meta.model + # Передаем request для проверки GET параметра ignore_validate + validate_entity_for_admin_form( + self, + cleaned_data, + main_field_name='s_artist', + metadata_field_name='j_artist_metadata', + request=self.request, + ) + + return cleaned_data + # Админка для TbArtist с кастомной формой ArtistAdminForm -class ArtistAdmin(admin.ModelAdmin): +class ArtistAdmin(RequestInFormMixin, admin.ModelAdmin): """Админ для артистов""" form = ArtistAdminForm # Используем кастомную форму с CodeMirror @@ -480,6 +518,41 @@ class ArtistAdmin(admin.ModelAdmin): }), ) + # Дополнительный CSS для валидации и вотчер для отслеживания изменений + class Media: + css = {'all': ('css/validation-override.css', )} # Стили для обхода валидации + js = ('js/form-field-watcher.js', ) # Вотчер для отслеживания изменений полей формы + + def save_model( + self, + request: HttpRequest, + obj: TbArtist, + form: forms.ModelForm, + change: bool, + ) -> None: + """ + Переопределяем save_model для добавления информативных сообщений в админку. + + Максимально поджарый код - все сложности с получением старого значения + и определением типа операции делает хелпер generate_admin_save_message(). + """ + # Стандартное сохранение записи через Django + # (в т.ч. автоматическое создание связанной статьи в методе save модели TbLabel) + super().save_model(request, obj, form, change) + + # Генерируем и отправляем информативное сообщение об сохранении + # Хелпер сам: + # - определяет тип операции (create vs update) + # - формирует нужное сообщение (success vs warning) + generate_admin_save_message( + request=request, + obj=obj, + is_new=not change, # Django: change=False для новых, True для существующих + related_article=obj.k_artist_to_article, + obj_field_name='s_artist', + article_title_field='s_article_title', + ) + # ================ # АДМИН-ПАНЕЛЬ ДЛЯ ЛЕЙБЛОВ/ИЗДАТЕЛЕЙ @@ -544,46 +617,11 @@ class LabelAdmin(RequestInFormMixin, admin.ModelAdmin): """Админ для лейблов с поддержкой передачи request в форму""" form = LabelAdminForm # Используем кастомную форму с CodeMirror - # Дополнительный CSS для валидации и вотчер для отслеживания изменений - class Media: - css = { - 'all': ( - 'css/validation-override.css', # Стили для обхода валидации - ) - } - js = ( - 'js/form-field-watcher.js', # Вотчер для отслеживания изменений полей формы - ) - list_display = ('id', 's_label', 't_label_created') list_display_links = ('id', 's_label',) search_fields = ('s_label',) readonly_fields = ('t_label_created', 't_label_updated') - def save_model(self, request, obj, form, change): - """ - Переопределяем save_model для добавления информативных сообщений в админку. - - Максимально поджарый код - все сложности с получением старого значения - и определением типа операции делает хелпер generate_admin_save_message(). - """ - # Стандартное сохранение записи через Django - # (в т.ч. автоматическое создание связанной статьи в методе save модели TbLabel) - super().save_model(request, obj, form, change) - - # Генерируем и отправляем информативное сообщение об сохранении - # Хелпер сам: - # - определяет тип операции (create vs update) - # - формирует нужное сообщение (success vs warning) - generate_admin_save_message( - request=request, - obj=obj, - is_new=not change, # Django: change=False для новых, True для существующих - related_article=obj.k_label_to_article, - obj_field_name='s_label', - article_title_field='s_article_title', - ) - fieldsets = ( ('Основные данные о лейбле/издателе', { 'fields': ('s_label', 'j_label_metadata',), @@ -599,6 +637,40 @@ class LabelAdmin(RequestInFormMixin, admin.ModelAdmin): }), ) + # Дополнительный CSS для валидации и вотчер для отслеживания изменений + class Media: + css = {'all': ('css/validation-override.css', )} # Стили для обхода валидации + js = ('js/form-field-watcher.js', ) # Вотчер для отслеживания изменений полей формы + + def save_model( + self, + request: HttpRequest, + obj: TbLabel, + form: forms.ModelForm, + change: bool, + ) -> None: + """ + Переопределяем save_model для добавления информативных сообщений в админку. + + Максимально поджарый код - все сложности с получением старого значения + и определением типа операции делает хелпер generate_admin_save_message(). + """ + # Стандартное сохранение записи через Django + # (в т.ч. автоматическое создание связанной статьи в методе save модели TbLabel) + super().save_model(request, obj, form, change) + + # Генерируем и отправляем информативное сообщение об сохранении + # Хелпер сам: + # - определяет тип операции (create vs update) + # - формирует нужное сообщение (success vs warning) + generate_admin_save_message( + request=request, + obj=obj, + is_new=not change, # Django: change=False для новых, True для существующих + related_article=obj.k_label_to_article, + obj_field_name='s_label', + article_title_field='s_article_title', + ) # ================ @@ -754,4 +826,3 @@ admin.site.register(TbOfferHistory, OfferHistoryAdmin) # Кастомизация админ-сайта через ready() в apps.py (переименование через verbose_name) # ============================================================================ - diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index 0204e54..c835c37 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -614,6 +614,42 @@ class TbArtist(models.Model): def __str__(self): return f"artist {self.id:0>4}: {self.s_artist}" + def save(self, *args, **kwargs): + """ + Переопределяем save для управления синонимами исполнителей и создания связанной статьи. + + При сохранении исполнителя (создание и обновление): + 1. Управляем синонимами: + - Для новых исполнителей: добавляем текущий s_artist в SYNONYM + - При изменении s_artist: добавляем как старый, так и новый s_artist в SYNONYM + - При редактировании: используем j_artist_metadata из формы (приоритет админу) + 2. Если статья не привязана - создаём новую статью исполнителя автоматически + 3. Генерируем технический заголовок и slug для статьи + """ + # ===== ВАЛИДАЦИЯ НА ДУБЛИКАТЫ ===== + # Проверяем ДО работы с синонимами и метаданными! + # Страховка: защита от прямого вызова save() минуя админку или (в будущем) парсер + validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata') + + # ===== УПРАВЛЕНИЕ СИНОНИМАМИ ===== + # Обновляем список синонимов в метаданных (универсальный хелпер для всех моделей) + update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata') + + # ===== СОЗДАНИЕ ИЛИ ПОЛУЧЕНИЕ СВЯЗАННОЙ СТАТЬИ ===== + # Используем универсальный хелпер для создания/поиска статьи + # Хелпер сам проверит через обратный FK, не дублирует статьи даже если админ переименовал + article = create_or_get_related_article( + self, + TbArticle.ArticleType.ARTIST, + 's_artist', + 'j_artist_metadata', + 'k_artist_to_article' # ← Явно передаем имя FK поля (избегаем "магии") + ) + self.k_artist_to_article = article + + # Вызываем оригинальный save родительского класса + super().save(*args, **kwargs) + class Meta: verbose_name = 'Исполнитель' verbose_name_plural = 'Исполнители'