From 9ba5ef2af6f4eb8329b5f65dd801dea161c80089 Mon Sep 17 00:00:00 2001 From: erjemin Date: Wed, 15 Jul 2026 17:20:54 +0300 Subject: [PATCH] =?UTF-8?q?mod:=20minor:=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=20=D1=84=D0=BE=D1=80=D0=BC,=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9=20(28)=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8F=20s=5Foffer=5Fskip32=20=D0=B2?= =?UTF-8?q?=20s=5Foffer=5Fcode=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20TbOffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lpon_site/frontend/admin.py | 4 +- ...r_tbarticle_k_article_to_image_and_more.py | 64 +++++++++++++++++ lpon_site/frontend/models.py | 72 ++++++++++++------- lpon_site/lpon_site/settings.py | 2 +- 4 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 lpon_site/frontend/migrations/0004_alter_tbarticle_k_article_to_image_and_more.py diff --git a/lpon_site/frontend/admin.py b/lpon_site/frontend/admin.py index f5bd565..11a1cfe 100644 --- a/lpon_site/frontend/admin.py +++ b/lpon_site/frontend/admin.py @@ -1289,10 +1289,10 @@ class OfferAdmin(RequestInFormMixin, admin.ModelAdmin): 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',) + readonly_fields = ('s_offer_code', 't_offer_created', 't_offer_updated',) fieldsets = ( ('Код товара в базе', { - 'fields': ('s_offer_skip32',), + 'fields': ('s_offer_code',), 'description': 'Уникальный код товара в базе. Используется вместо ID для генерации ссылок на оффер' ' и складского учёта с помощью QR-кодов. Не редактируется вручную.', }), diff --git a/lpon_site/frontend/migrations/0004_alter_tbarticle_k_article_to_image_and_more.py b/lpon_site/frontend/migrations/0004_alter_tbarticle_k_article_to_image_and_more.py new file mode 100644 index 0000000..9137e7e --- /dev/null +++ b/lpon_site/frontend/migrations/0004_alter_tbarticle_k_article_to_image_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.5 on 2026-07-15 14:00 + +import django.db.models.deletion +import django.utils.timezone +import filer.fields.image +import frontend.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('frontend', '0003_remove_tbimagemetadata_f_img_confidence_score_and_more'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='tbarticle', + name='k_article_to_image', + field=filer.fields.image.FilerImageField(blank=True, help_text='Обложка или иллюстрация статьи из файлового хранилища (django_filer). Метаданные: image.metadata.i_img_sort и image.metadata.j_img_metadata.
ВАЖНО: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает SVG, WebP и PNG).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='image_to_article', to=settings.FILER_IMAGE_MODEL, verbose_name='Изображение для статьи'), + ), + migrations.AlterField( + model_name='tbimagemetadata', + name='j_img_metadata', + field=models.JSONField(blank=True, default=frontend.models.get_image_metadata_default, help_text='JSON с дополнительными данными: тип (реальное/абстрактное), источник (парсер/админка), URL источника и заметки. Предзаполнено структурой для удобства.', verbose_name='Метаданные'), + ), + migrations.AlterField( + model_name='tboffer', + name='d_offer_date_release', + field=models.DateField(blank=True, db_index=True, default=None, help_text='Дата релиза (например, дата выпуска переиздания) или выхода по предзаказу, если она известна', null=True, verbose_name='Дата релиза'), + ), + migrations.AlterField( + model_name='tboffer', + name='i_offer_discount_to_daily_sale', + field=models.IntegerField(blank=True, db_index=True, default=0, help_text='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано 0 то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', verbose_name='Возможна cкидка (%)'), + ), + migrations.AlterField( + model_name='tboffer', + name='j_offer_metadata', + field=models.JSONField(default=frontend.models.get_offer_metadata_default, help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:
{\n  "OFR_NT": "Уценка: замят угол конверта, повреждены наклейки.",\n  "OFR_MF": {\n    "lp": 2,\n    "cd": 1\n  }\n}
Ключи:', null=True, verbose_name='Дополнительные данные'), + ), + migrations.AlterField( + model_name='tboffer', + name='k_offer_to_label', + field=models.ForeignKey(blank=True, default=None, help_text='Лейбл, на котором был выпущен релиз (если известен), или производитель для аудиио-кассет и MD под запись, технику или аксессуар.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_to_offer', to='frontend.tblabel', verbose_name='Лейбл/Производитель'), + ), + migrations.AlterField( + model_name='tboffer', + name='l_offer_to_format', + field=models.CharField(choices=[('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')], db_index=True, default='??', help_text='Форматы основного носителей (пластинка, CD, кассета и т.п.). Если несколько носителей (и разных), то это указывать в "Дополнительных данных" в JSON-формате. Например: {"OFR_MF": {"lp": 2, "cd": 1},}', max_length=2, verbose_name='Формат'), + ), + migrations.AlterField( + model_name='tboffer', + name='s_offer_code', + field=models.CharField(help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id). Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера. Не редактировать вручную!', max_length=12, unique=True, verbose_name='Код товара'), + ), + migrations.AlterField( + model_name='tbofferhistory', + name='t_history_created', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='Дата создания записи истории. Автоматически устанавливается на текущее время при создании, но может быть отредактирована для загрузки исторических данных из Excel-файлов.', verbose_name='Дата создания'), + ), + ] diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index 85ecb62..b372a48 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -148,7 +148,7 @@ # │ f_offer_price │ Цена [indexed для сортировки] # │ i_offer_quantity │ Количество в наличии [indexed] # │ i_offer_discount_to_daily_sale │ % скидка [indexed для фильтров] -# │ s_offer_skip32 │ Хеш для корзины (unique) +# │ s_offer_code │ Хеш для корзины (unique) # │ i_offer_views │ Счетчик просмотров # │ i_offer_favorites │ Счетчик в избранном # │ t_offer_created │ Timestamp @@ -267,6 +267,34 @@ from lpon_site.settings import ( logger = logging.getLogger(__name__) +# ============================================================================ +# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ DEFAULTS В МОДЕЛЯХ +# ============================================================================ + +def get_image_metadata_default(): + """ + Возвращает значение по умолчанию для j_img_metadata. + Используется как callable default в Django моделях (Django не может сериализовать lambda). + """ + return { + KEY_IMAGE_TYPE: VALUE_IMAGE_ABSTRACT, + KEY_IMAGE_FROM: VALUE_IMAGE_FROM_USER, + KEY_IMAGE_URL: None, + KEY_IMAGE_NOTE: '???', + } + + +def get_offer_metadata_default(): + """ + Возвращает значение по умолчанию для j_offer_metadata. + Используется как callable default в Django моделях (Django не может сериализовать lambda). + """ + return { + KEY_OFFER_NOTE: '', + KEY_OFFER_ALL_MEDIA: [], + } + + # ============================================================================ # МЕТАДАННЫЕ ИЗОБРАЖЕНИЙ # ============================================================================ @@ -312,12 +340,7 @@ class TbImageMetadata(models.Model): j_img_metadata = models.JSONField( # Гибкие дополнительные данные об изображении с предзаполненной структурой # Пример: {"IMG_IS": "abstract", "IMG_FROM": "ivan", "IMG_URL": None, "IMG_NOTE": "обложка"} - default=lambda: { - KEY_IMAGE_TYPE: VALUE_IMAGE_ABSTRACT, - KEY_IMAGE_FROM: VALUE_IMAGE_FROM_USER, - KEY_IMAGE_URL: None, - KEY_IMAGE_NOTE: '???', # Автогенерация через парсер или админку - }, + default=get_image_metadata_default, blank=True, null=False, # Изменено с True на False т.к. теперь всегда есть структура default verbose_name='Метаданные', @@ -1287,10 +1310,7 @@ class TbOffer(models.Model): ) j_offer_metadata = models.JSONField( # Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.) - default=lambda: { - KEY_OFFER_NOTE: '', - KEY_OFFER_ALL_MEDIA: [], - }, + default=get_offer_metadata_default, null=True, verbose_name='Дополнительные данные', help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:
',
     )
-    s_offer_skip32 = models.CharField(
+    s_offer_code = models.CharField(
         max_length=12,
         unique=True,
         verbose_name='Код товара',
         help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
-                  ' Например: "4gfFCJ". Формируется автоматически связкой Skip32 (хаотичное перемешивание) и'
-                  ' Base62 (компактная упаковка) из id оффера в методе save().',
+                  ' Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера.'
+                  ' Не редактировать вручную!',
     )
     i_offer_views = models.IntegerField(
         default=0,
@@ -1332,7 +1352,7 @@ class TbOffer(models.Model):
     def __str__(self):
         seller = self.k_offer_to_source.k_source_to_seller.s_seller if (self.k_offer_to_source
                                                               and self.k_offer_to_source.k_source_to_seller) else "?"
-        return (f"[{self.s_offer_skip32:0>6}]\u00A0«{self.s_offer}»"
+        return (f"[{self.s_offer_code:0>6}]\u00A0«{self.s_offer}»"
                 f" ⟶\u00A0item\u00A0«{self.k_offer_to_item_id}»"
                 f" ⟶\u00A0seller\u00A0«{seller}»")
 
@@ -1351,10 +1371,10 @@ class TbOffer(models.Model):
     def save(self, *args, **kwargs):
         """
         Переопределенный метод save для:
-        1. Автоматического формирования s_offer_skip32 (криптографический код)
+        1. Автоматического формирования s_offer_code (криптографический код)
         2. Записи истории изменений цены и количества в TbOfferHistory
         
-        ЛОГИКА s_offer_skip32:
+        ЛОГИКА s_offer_code:
         - Для новых оферов: генерируем код после получения ID
         - Для старых оферов БЕЗ кода: кодируем существующий ID (миграция)
         - Для старых оферов С кодом: не трогаем
@@ -1364,25 +1384,25 @@ class TbOffer(models.Model):
         - При обновлении: если цена или количество изменилось → создаем новую запись
         - Это позволяет отследить полную историю изменений
         """
-        # 1. Проверяем нужно ли генерировать s_offer_skip32:
+        # 1. Проверяем нужно ли генерировать s_offer_code:
         # - ИЛИ это новый объект (self.pk == None)
-        # - ИЛИ это старый объект без s_offer_skip32 (миграция)
-        if not self.pk or not self.s_offer_skip32:
+        # - ИЛИ это старый объект без s_offer_code (миграция)
+        if not self.pk or not self.s_offer_code:
             # Если это новый оффер, сначала сохраняем, чтобы получить ID
             if not self.pk:
-                # Сохраняем БЕЗ s_offer_skip32 чтобы Django создал запись и присвоил pk
+                # Сохраняем БЕЗ s_offer_code чтобы Django создал запись и присвоил pk
                 super().save(*args, **kwargs)
                 # После save() Django автоматически заполнит self.pk
 
             # Кодируем pk в компактный, необратимый код
             # Пример: pk=42 → "QBErd8"
-            self.s_offer_skip32 = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH).encode(self.pk)
+            self.s_offer_code = Hashids(salt=OFFER_HASHIDS_SALT, min_length=OFFER_HASHIDS_MIN_LENGTH).encode(self.pk)
 
-            # Сохраняем только поле s_offer_skip32 (не перезаписываем остальное)
-            super().save(update_fields=['s_offer_skip32'])
+            # Сохраняем только поле s_offer_code (не перезаписываем остальное)
+            super().save(update_fields=['s_offer_code'])
         else:
-            # Оффер существует И уже имеет s_offer_skip32: сохраняем как обычно
-            # Не трогаем s_offer_skip32, он был сформирован при создании
+            # Оффер существует И уже имеет s_offer_code: сохраняем как обычно
+            # Не трогаем s_offer_code, он был сформирован при создании
             super().save(*args, **kwargs)
 
         # 2. Записывает историю изменений цены и количества в TbOfferHistory.
diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py
index f3c8888..95064ad 100644
--- a/lpon_site/lpon_site/settings.py
+++ b/lpon_site/lpon_site/settings.py
@@ -48,7 +48,7 @@ ALLOWED_HOSTS = env.list(
 
 CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver'])
 
-# ====== HASHIDS настройка для s_offer_skip32 ======
+# ====== HASHIDS настройка для s_offer_code ======
 # Криптографическое кодирование ID оферов в компактные, необратимые коды.
 # Благодаря hashids невозможно восстановить исходный ID из кода,
 # даже зная алгоритм (в отличие от простого XOR).