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-словаря. Например:
\"{KEY_OFFER_ALL_MEDIA}\" — Какие носители входят в коммерческое предложение. '
+ ' Допустимые значения: \"lp\" — Vinyl Long-Play (12"), \"ep\" — Vinyl Extended-Play (12\", 10\",'
+ ' 7\"), \"45\" — Vinyl 7\" (45 rpm), \"cd\" — Compact Disc, \"ld\" — LaserDisc,'
+ ' \"md\" — MiniDisc Record, \"ms\" — Used MiniDisc (для записи),'
+ ' \"cs\" — Cassette Record, \"uc\" — Used Cassette (для записи),'
+ ' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
+ ' \"??\" — Other
',
)
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'