diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index ac23c79..13612c7 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -947,13 +947,13 @@ class ArticleAdminForm(CodeMirrorFormMixin): label="Кавычки", initial=True, required=False, - help_text="Заменять кавычки
(«ёлочки» для русского, “лапки” для английского)   " + help_text="Заменять кавычки
(«ёлочки» для русского, “лапки” для английского)   " ) etp_hyphenation = forms.BooleanField( label="Расставлять переносы", initial=True, required=False, - help_text="Расставлять мягкие переносы (­)   
" + help_text="Расставлять мягкие переносы (­)   
" "в словах длиннее 14 символов   " ) etp_sanitize = forms.BooleanField( @@ -966,7 +966,7 @@ class ArticleAdminForm(CodeMirrorFormMixin): label="Висячая пунктуация", initial=False, required=False, - help_text="Выносить пунктуацию в начало строк
   " + help_text="Выносить пунктуацию в начало строк
   " "(только для заголовков... для тизера и контента отключается автоматически)   " ) etp_mode = forms.ChoiceField( @@ -1045,14 +1045,14 @@ class ArticleAdminForm(CodeMirrorFormMixin): # ...предлагаем изменить slug error_html = ( '
' - ' Ой! Кажется, вы изменили заголовок статьи!
' - f' Текущий slug: {self.instance.slug}
' + ' Ой! Кажется, вы изменили заголовок статьи!
' + f' Текущий slug: {self.instance.slug}
' f' Для измененного заголовка «{cleaned_data.get('s_article_title_html')}»' - f' лучше сделать slug {new_potential_slug}

' - ' Пожалуйста, проверьте, что это правильно. Если вы уверены — нажмите подтверждение.

' + f' лучше сделать slug {new_potential_slug}

' + ' Пожалуйста, проверьте, что это правильно. Если вы уверены — нажмите подтверждение.

' '
 ' + '
 ' '
' ) raise ValidationError(mark_safe(error_html)) @@ -1193,13 +1193,92 @@ class ArticleAdmin(RequestInFormMixin, admin.ModelAdmin): super().save_model(request, obj, form, change) +# ============================================================================ +# АДМИНКА КОММЕРЧЕСКИХ ПРЕДЛОЖЕНИЙ (ОФФЕРОВ) +# +# Статьи очень важная сущность сайта. Через них на сайте отпределяютя URL (slag), SEO-поля и "заголовочные" картинки +# многих сущностей (артистов, лейблов, музыкальных стилей и т.д.). Также тут опредлелентся, как на сайте будут +# отображаться все эти сущности сверстанными в HTML. + +# Кастомная форма +class OfferAdminForm(CodeMirrorFormMixin): + """ + Кастомная форма для админки коммерческих предложений (TbOffer). + Добавляет виджеты CodeMirror для текстовых полей + """ + class Meta: + model = TbOffer + fields = ( + 's_offer', + 'l_offer_to_format', + 'k_offer_to_article', + 'k_offer_to_item', + 'k_offer_to_label', + 'k_offer_to_source', + 's_offer_catalog_num', + 'b_offer_is_preorder', + 'd_offer_date_release', + 'i_offer_discogs_id', + 'l_offer_condition_media', + 'l_offer_condition_sleeve', + 'f_offer_price', + 'i_offer_quantity', + 'i_offer_discount_to_daily_sale', + 'j_offer_metadata', + 'i_offer_views', + 'i_offer_favorites', + ) + + def __init__(self, *args, **kwargs): + """ + При инициализации формы подгружаем CodeMirror редактор. + Получаем request из kwargs, переданных из get_form_kwargs в AdminClass. + """ + # Извлекаем request из kwargs если он есть + self.request = kwargs.pop('request', None) + + super().__init__(*args, **kwargs) + + # Конфигурируем поля для CodeMirror + self.setup_codemirror_field('s_offer', language='text', + css_class='codemirror-width-l codemirror-no-lines') + self.setup_codemirror_field('f_offer_price', language='text', + css_class='codemirror-width-s codemirror-no-lines') + self.setup_codemirror_field('i_offer_quantity', language='text', + css_class='codemirror-width-s codemirror-no-lines') + self.setup_codemirror_field('j_offer_metadata', language='json', + css_class='codemirror-width-l codemirror-min-height-5') + self.setup_codemirror_field('i_offer_views', language='text', + css_class='codemirror-width-s codemirror-no-lines') + self.setup_codemirror_field('i_offer_favorites', language='text', + css_class='codemirror-width-s codemirror-no-lines') + self.setup_codemirror_field('s_offer_catalog_num', language='text', + css_class='codemirror-width-m codemirror-no-lines') + self.setup_codemirror_field('i_offer_discogs_id', language='text', + css_class='codemirror-width-m codemirror-no-lines') + self.setup_codemirror_field('i_offer_discount_to_daily_sale', language='text', + css_class='codemirror-width-s codemirror-no-lines') # ============================================================================ -# Остальные ModelAdmin классы -# Их еще предстоит обжужукать (натянуть CodeMirror, обвесить валидаторами, переопределить save_model и т.д.) +# АДМИНКА ОФФЕРОВ (OfferAdminForm, OfferImageInlineForm, OfferAdmin) # ============================================================================ -class OfferAdmin(admin.ModelAdmin): - """Админ для предложений""" + +class OfferImageInlineForm(CodeMirrorFormMixin): + """Форма для inline картинок с CodeMirror стилями""" + class Meta: + model = TbImageMetadata + fields = ('image', 'i_img_sort') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Стилизуем i_img_sort в стиле CodeMirror (как число, компактное) + self.setup_codemirror_field('i_img_sort', language='text', + css_class='codemirror-width-s codemirror-no-lines') + +class OfferAdmin(RequestInFormMixin, admin.ModelAdmin): + """Админ для предложений с поддержкой передачи request в форму""" + form = OfferAdminForm # Используем кастомную форму с CodeMirror + class OfferImageInline(admin.TabularInline): """ @@ -1208,21 +1287,69 @@ class OfferAdmin(admin.ModelAdmin): Позволяет добавлять, редактировать и удалять картинки через: - FilerImageField для красивого выбора - i_img_sort для сортировки - - j_img_metadata для метаданных """ model = TbImageMetadata + form = OfferImageInlineForm fk_name = 'm_offer' extra = 1 # одна пустая строка для добавления новой картинки - fields = ('image', 'i_img_sort', 'j_img_metadata') + fields = ('image', 'i_img_sort') ordering = ('i_img_sort',) + verbose_name = 'Изображения и метаданные-изображений коммерческого предложения' + verbose_name_plural = 'Изображения и метаданные-изображений коммерческих предложений' list_display = ('id', 's_offer', 'k_offer_to_item', 'f_offer_price', 'i_offer_quantity', 'i_offer_views') + list_display_links = ('id', 's_offer',) list_filter = ('l_offer_condition_media', 'l_offer_condition_sleeve', 't_offer_created', 'l_offer_to_format') search_fields = ('s_offer',) inlines = [OfferImageInline] # Управление картинками в inline - readonly_fields = ('s_offer_skip32', 't_offer_created', 't_offer_updated', 'i_offer_views', 'i_offer_favorites') + readonly_fields = ('s_offer_skip32', 't_offer_created', 't_offer_updated',) + fieldsets = ( + ('Код товара в базе', { + 'fields': ('s_offer_skip32',), + }), + ('Основная информация', { + 'fields': ('k_offer_to_item', 's_offer', 'f_offer_price', 'i_offer_quantity'), + }), + ('Дата релиза и метка если доступно только по предзаказу', { + 'fields': ('d_offer_date_release', 'b_offer_is_preorder',), + 'classes': ('collapse',), + }), + ('Дополнительные данные', { + 'fields': ( + 'l_offer_to_format', + 'l_offer_condition_media', + 'l_offer_condition_sleeve', + 'k_offer_to_article', + 'k_offer_to_label', + 'k_offer_to_source', + 's_offer_catalog_num', + 'i_offer_discogs_id', + + 'i_offer_discount_to_daily_sale', + ), + }), + ('Метаданные коммерческого предложения', { + 'fields': ('j_offer_metadata',), + 'classes': ('collapse',), + }), + ('Просмотры и избранное', { + 'fields': ('i_offer_views', 'i_offer_favorites',), + 'classes': ('collapse',), + }), + ('Служебная информация', { + 'fields': ('t_offer_created', 't_offer_updated'), + 'classes': ('collapse',), + }), + ) + + + +# ============================================================================ +# Остальные ModelAdmin классы +# Их еще предстоит обжужукать (натянуть CodeMirror, обвесить валидаторами, переопределить save_model и т.д.) +# ============================================================================ class OfferHistoryAdmin(admin.ModelAdmin): """Админ для истории изменений офферов""" list_display = ('id', 'k_history_to_offer', 'f_history_price', 'i_history_quantity', 't_history_created') diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index ec0d3be..ed3b040 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -255,6 +255,8 @@ from lpon_site.settings import ( VALUE_IMAGE_FROM_USER, # Картинка загружена пользователем через админку KEY_IMAGE_URL, # Ключ, о том, откуда получена картинка (URL источника) KEY_IMAGE_NOTE, # Ключ, для заметок о картинке (например, "обложка", "задник", "вкладка" и т.д.) + KEY_OFFER_NOTE, # Ключ, для заметок о коммерческом предложении (например, "замят угол конверта" и т.д.) + KEY_OFFER_ALL_MEDIA, # Ключ, для хранения информации о том, какие носители входят в оффер (например, CD+2LP) ) logger = logging.getLogger(__name__) @@ -414,7 +416,7 @@ class TbArticle(models.Model): db_index=True, verbose_name='Изображение для статьи', help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer).' - ' Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.
' + ' Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.
' 'ВАЖНО: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям,' ' её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае ' ' рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).' @@ -1151,7 +1153,7 @@ class TbOffer(models.Model): verbose_name='Формат', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных),' ' то это указывать в "Дополнительных данных" в JSON-формате. Например:' - ' {"formats": {"lp": 2, "cd": 1},}', + ' {"OFR_MF": {"lp": 2, "cd": 1},}', ) # Связи k_offer_to_article = models.ForeignKey( @@ -1179,13 +1181,15 @@ class TbOffer(models.Model): ) k_offer_to_label = models.ForeignKey( TbLabel, + blank=True, null=True, default=None, on_delete=models.SET_NULL, related_name='label_to_offer', # ← label.label_to_offers.all() db_index=True, # Принудительно создаем индекс, т.к. SQLite их сам не создаст. - verbose_name='Лейбл', - help_text='Лейбл, на котором был выпущен релиз, если он известен. Например: Atlantic или Мелодия', + verbose_name='Лейбл/Производитель', + help_text='Лейбл, на котором был выпущен релиз (если известен), или производитель для аудиио-кассет и MD' + ' под запись, технику или аксессуар.', ) k_offer_to_source = models.ForeignKey( to='TbSource', @@ -1226,7 +1230,7 @@ class TbOffer(models.Model): help_text='Если товар доступен только для предзаказа', ) d_offer_date_release = models.DateField( - blank=False, + blank=True, null=True, default=None, db_index=True, @@ -1278,9 +1282,26 @@ class TbOffer(models.Model): ) j_offer_metadata = models.JSONField( # Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.) - default=dict, null=True, + default={ + KEY_OFFER_NOTE: '', + KEY_OFFER_ALL_MEDIA: [], + }, + null=True, verbose_name='Дополнительные данные', - help_text='Дополнительные данные о предложении в виде JSON-словаря.', + help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:
{\n'
+                  f'  \"{KEY_OFFER_NOTE}\": \"Уценка: замят угол конверта, повреждены наклейки.\",\n'
+                  f'  \"{KEY_OFFER_ALL_MEDIA}\": '
+                  '{\n    "lp": 2,\n    "cd": 1\n  }\n}
' + f'Ключи:', ) s_offer_skip32 = models.CharField( max_length=12, diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index 4d67578..1227362 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -330,6 +330,10 @@ VALUE_IMAGE_FROM_PARSE = 'parse' # Картинка получена через KEY_IMAGE_URL = 'IMG_URL' # Ключ для хранения URL оригинальной картинки (обычно это должен прописать парсер) KEY_IMAGE_NOTE = 'IMG_NOTE' # Ключ для хранения заметки о картинке +# Ключи и значения для коммерческих предложений (офферов, см. TbOffer и OfferAdmin) +KEY_OFFER_NOTE = 'OFR_NT' # Ключ для заметок по офферу (например, "замят угол конверта" и т.д.) +KEY_OFFER_ALL_MEDIA = 'OFR_MF' # Ключ для хранения информации о том, какие носители входят в оффер (например, CD+2LP) + # ДЛЯ ВАЛИДАЦИИ (поиска похожих исполнителей, стилей и т.д. (используется в TbLabel.matching_type, TbSeller.matching_type и т.д.) VALIDATE_KEY__MATCH_TYPE = 'MATCH_TYPE'