mod: minor: валидатор форм, парсера и моделей (28) миграции и переименование поля s_offer_skip32 в s_offer_code в модели TbOffer

This commit is contained in:
2026-07-15 17:20:54 +03:00
parent a2986cf719
commit 9ba5ef2af6
4 changed files with 113 additions and 29 deletions
+2 -2
View File
@@ -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-кодов. Не редактируется вручную.',
}),
@@ -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.<br/><b>ВАЖНО</b>: Так как статья призывается к исполнителям, лейблам, продавцам, музыкальным стилям, её можно использовать как логотип-пкитограмму или баннер этих сущностей. В этом случае рекомендуется использовать изображение с прозрачным фоном (движок поддерживает 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='Процент возможной скидки, если участвует в "ежедневной распродаже" или акции. Если указано <tt>0</tt> то данное предложение не может участвовать в распродажах, спецпредложениях и акциях', 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-словаря. Например:<pre style="background-color:#80808080;border:1px solid #ccc; padding:1ex;max-width: calc(75% - 13em);">{\n "OFR_NT": "Уценка: замят угол конверта, повреждены наклейки.",\n "OFR_MF": {\n "lp": 2,\n "cd": 1\n }\n}</pre>Ключи:<ul> <li>"OFR_NT" — Примечание к офферу</li> <li>"OFR_MF" — Какие носители входят в коммерческое предложение.<br/> Допустимые значения: "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</li></ul>', 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-формате. Например: <tt>{"OFR_MF": {"lp": 2, "cd": 1},}</tt>', max_length=2, verbose_name='Формат'),
),
migrations.AlterField(
model_name='tboffer',
name='s_offer_code',
field=models.CharField(help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id). Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера. <b>Не редактировать вручную!</b>', 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='Дата создания'),
),
]
+46 -26
View File
@@ -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-словаря. Например:<pre style=\"'
@@ -1308,13 +1328,13 @@ class TbOffer(models.Model):
' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
' \"??\" — Other</li></ul>',
)
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 оффера.'
' <b>Не редактировать вручную!</b>',
)
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.