mod: minor: валидатор форм, парсера и моделей (28) миграции и переименование поля s_offer_skip32 в s_offer_code в модели TbOffer
This commit is contained in:
@@ -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')
|
list_filter = ('l_offer_condition_media', 'l_offer_condition_sleeve', 't_offer_created', 'l_offer_to_format')
|
||||||
search_fields = ('s_offer',)
|
search_fields = ('s_offer',)
|
||||||
inlines = [OfferImageInline] # Управление картинками в inline
|
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 = (
|
fieldsets = (
|
||||||
('Код товара в базе', {
|
('Код товара в базе', {
|
||||||
'fields': ('s_offer_skip32',),
|
'fields': ('s_offer_code',),
|
||||||
'description': 'Уникальный код товара в базе. Используется вместо ID для генерации ссылок на оффер'
|
'description': 'Уникальный код товара в базе. Используется вместо ID для генерации ссылок на оффер'
|
||||||
' и складского учёта с помощью QR-кодов. Не редактируется вручную.',
|
' и складского учёта с помощью 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='Дата создания'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
# │ f_offer_price │ Цена [indexed для сортировки]
|
# │ f_offer_price │ Цена [indexed для сортировки]
|
||||||
# │ i_offer_quantity │ Количество в наличии [indexed]
|
# │ i_offer_quantity │ Количество в наличии [indexed]
|
||||||
# │ i_offer_discount_to_daily_sale │ % скидка [indexed для фильтров]
|
# │ i_offer_discount_to_daily_sale │ % скидка [indexed для фильтров]
|
||||||
# │ s_offer_skip32 │ Хеш для корзины (unique)
|
# │ s_offer_code │ Хеш для корзины (unique)
|
||||||
# │ i_offer_views │ Счетчик просмотров
|
# │ i_offer_views │ Счетчик просмотров
|
||||||
# │ i_offer_favorites │ Счетчик в избранном
|
# │ i_offer_favorites │ Счетчик в избранном
|
||||||
# │ t_offer_created │ Timestamp
|
# │ t_offer_created │ Timestamp
|
||||||
@@ -267,6 +267,34 @@ from lpon_site.settings import (
|
|||||||
logger = logging.getLogger(__name__)
|
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(
|
j_img_metadata = models.JSONField(
|
||||||
# Гибкие дополнительные данные об изображении с предзаполненной структурой
|
# Гибкие дополнительные данные об изображении с предзаполненной структурой
|
||||||
# Пример: {"IMG_IS": "abstract", "IMG_FROM": "ivan", "IMG_URL": None, "IMG_NOTE": "обложка"}
|
# Пример: {"IMG_IS": "abstract", "IMG_FROM": "ivan", "IMG_URL": None, "IMG_NOTE": "обложка"}
|
||||||
default=lambda: {
|
default=get_image_metadata_default,
|
||||||
KEY_IMAGE_TYPE: VALUE_IMAGE_ABSTRACT,
|
|
||||||
KEY_IMAGE_FROM: VALUE_IMAGE_FROM_USER,
|
|
||||||
KEY_IMAGE_URL: None,
|
|
||||||
KEY_IMAGE_NOTE: '???', # Автогенерация через парсер или админку
|
|
||||||
},
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=False, # Изменено с True на False т.к. теперь всегда есть структура default
|
null=False, # Изменено с True на False т.к. теперь всегда есть структура default
|
||||||
verbose_name='Метаданные',
|
verbose_name='Метаданные',
|
||||||
@@ -1287,10 +1310,7 @@ class TbOffer(models.Model):
|
|||||||
)
|
)
|
||||||
j_offer_metadata = models.JSONField(
|
j_offer_metadata = models.JSONField(
|
||||||
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
|
# Метаданные оффера (сырые данные из источника, координаты в Excel и т.д.)
|
||||||
default=lambda: {
|
default=get_offer_metadata_default,
|
||||||
KEY_OFFER_NOTE: '',
|
|
||||||
KEY_OFFER_ALL_MEDIA: [],
|
|
||||||
},
|
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name='Дополнительные данные',
|
verbose_name='Дополнительные данные',
|
||||||
help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:<pre style=\"'
|
help_text='Дополнительные данные о предложении в виде JSON-словаря. Например:<pre style=\"'
|
||||||
@@ -1308,13 +1328,13 @@ class TbOffer(models.Model):
|
|||||||
' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
|
' \"tp\" — Tape Reel Record, \"ur\" — Used Tape Reel (для записи),'
|
||||||
' \"??\" — Other</li></ul>',
|
' \"??\" — Other</li></ul>',
|
||||||
)
|
)
|
||||||
s_offer_skip32 = models.CharField(
|
s_offer_code = models.CharField(
|
||||||
max_length=12,
|
max_length=12,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name='Код товара',
|
verbose_name='Код товара',
|
||||||
help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
|
help_text='Уникальный код товара для идентификации в корзине и при заказе (чтобы не светить id).'
|
||||||
' Например: "4gfFCJ". Формируется автоматически связкой Skip32 (хаотичное перемешивание) и'
|
' Например: "4gfFCJ". Формируется автоматически через Hashids на основе ID оффера.'
|
||||||
' Base62 (компактная упаковка) из id оффера в методе save().',
|
' <b>Не редактировать вручную!</b>',
|
||||||
)
|
)
|
||||||
i_offer_views = models.IntegerField(
|
i_offer_views = models.IntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
@@ -1332,7 +1352,7 @@ class TbOffer(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
seller = self.k_offer_to_source.k_source_to_seller.s_seller if (self.k_offer_to_source
|
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 "?"
|
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" ⟶\u00A0item\u00A0«{self.k_offer_to_item_id}»"
|
||||||
f" ⟶\u00A0seller\u00A0«{seller}»")
|
f" ⟶\u00A0seller\u00A0«{seller}»")
|
||||||
|
|
||||||
@@ -1351,10 +1371,10 @@ class TbOffer(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Переопределенный метод save для:
|
Переопределенный метод save для:
|
||||||
1. Автоматического формирования s_offer_skip32 (криптографический код)
|
1. Автоматического формирования s_offer_code (криптографический код)
|
||||||
2. Записи истории изменений цены и количества в TbOfferHistory
|
2. Записи истории изменений цены и количества в TbOfferHistory
|
||||||
|
|
||||||
ЛОГИКА s_offer_skip32:
|
ЛОГИКА s_offer_code:
|
||||||
- Для новых оферов: генерируем код после получения ID
|
- Для новых оферов: генерируем код после получения ID
|
||||||
- Для старых оферов БЕЗ кода: кодируем существующий ID (миграция)
|
- Для старых оферов БЕЗ кода: кодируем существующий ID (миграция)
|
||||||
- Для старых оферов С кодом: не трогаем
|
- Для старых оферов С кодом: не трогаем
|
||||||
@@ -1364,25 +1384,25 @@ class TbOffer(models.Model):
|
|||||||
- При обновлении: если цена или количество изменилось → создаем новую запись
|
- При обновлении: если цена или количество изменилось → создаем новую запись
|
||||||
- Это позволяет отследить полную историю изменений
|
- Это позволяет отследить полную историю изменений
|
||||||
"""
|
"""
|
||||||
# 1. Проверяем нужно ли генерировать s_offer_skip32:
|
# 1. Проверяем нужно ли генерировать s_offer_code:
|
||||||
# - ИЛИ это новый объект (self.pk == None)
|
# - ИЛИ это новый объект (self.pk == None)
|
||||||
# - ИЛИ это старый объект без s_offer_skip32 (миграция)
|
# - ИЛИ это старый объект без s_offer_code (миграция)
|
||||||
if not self.pk or not self.s_offer_skip32:
|
if not self.pk or not self.s_offer_code:
|
||||||
# Если это новый оффер, сначала сохраняем, чтобы получить ID
|
# Если это новый оффер, сначала сохраняем, чтобы получить ID
|
||||||
if not self.pk:
|
if not self.pk:
|
||||||
# Сохраняем БЕЗ s_offer_skip32 чтобы Django создал запись и присвоил pk
|
# Сохраняем БЕЗ s_offer_code чтобы Django создал запись и присвоил pk
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
# После save() Django автоматически заполнит self.pk
|
# После save() Django автоматически заполнит self.pk
|
||||||
|
|
||||||
# Кодируем pk в компактный, необратимый код
|
# Кодируем pk в компактный, необратимый код
|
||||||
# Пример: pk=42 → "QBErd8"
|
# Пример: 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 (не перезаписываем остальное)
|
# Сохраняем только поле s_offer_code (не перезаписываем остальное)
|
||||||
super().save(update_fields=['s_offer_skip32'])
|
super().save(update_fields=['s_offer_code'])
|
||||||
else:
|
else:
|
||||||
# Оффер существует И уже имеет s_offer_skip32: сохраняем как обычно
|
# Оффер существует И уже имеет s_offer_code: сохраняем как обычно
|
||||||
# Не трогаем s_offer_skip32, он был сформирован при создании
|
# Не трогаем s_offer_code, он был сформирован при создании
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# 2. Записывает историю изменений цены и количества в TbOfferHistory.
|
# 2. Записывает историю изменений цены и количества в TbOfferHistory.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ ALLOWED_HOSTS = env.list(
|
|||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver'])
|
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 оферов в компактные, необратимые коды.
|
# Криптографическое кодирование ID оферов в компактные, необратимые коды.
|
||||||
# Благодаря hashids невозможно восстановить исходный ID из кода,
|
# Благодаря hashids невозможно восстановить исходный ID из кода,
|
||||||
# даже зная алгоритм (в отличие от простого XOR).
|
# даже зная алгоритм (в отличие от простого XOR).
|
||||||
|
|||||||
Reference in New Issue
Block a user