From 460adbb65b320e6d9e215e3664ebc1dfa9633321 Mon Sep 17 00:00:00 2001 From: erjemin Date: Mon, 13 Jul 2026 23:48:27 +0300 Subject: [PATCH] =?UTF-8?q?minor:=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=84=D0=BE=D1=80=D0=BC,=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=81=D0=B5=D1=80=D0=B0=20=D0=B8=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9=20(26)=20=D0=B2=20save=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=20TbOffer=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=B5=D0=BC=20=D1=85=D0=B0=D0=BE=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=B4=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20id=20(=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=B7=D0=BB=D0=BE?= =?UTF-8?q?=D1=83=D0=BC=D1=8B=D1=88=D0=BB=D0=B5=D0=BD=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BB=D0=B8=20=D0=BC=D0=B0=D1=85?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8.=20=D0=9A=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B3=D0=BB=D1=8F=D0=B4=D0=B8=D1=82=20=D1=85=D0=B0=D0=BE=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D0=BE=20=D0=B8=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 ++++++++ lpon_site/frontend/models.py | 53 +++++++++++++++++++++++++++++++++ lpon_site/lpon_site/settings.py | 7 +++++ poetry.lock | 16 +++++++++- pyproject.toml | 1 + 5 files changed, 89 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d80dcd1..b6a0012 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,19 @@ DJANGO_ADMINS=YouName:you@email.com # Укажите здесь URL, по которому вы заходите на сайт (с протоколом и портом gunicorn) DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://testserver,https://lpon.ru +# ====== HASHIDS настройка для s_offer_skip32 ====== +# Используется для кодирования ID коммерческих предложений в компактные, хаотичные коды (не криптографический хеш, а encoder). +# - OFFER_HASHIDS_SALT: секретная соль для кодирования (ВАЖНО! Менять для production!) +# Только с солью можно декодировать, без нее невозможно восстановить ID +# Генерируйте криптографически стойкую случайную строку: python -c "import secrets; print(secrets.token_hex(16))" +# Содержит только буквы и цифры (безопасно для URL и QR-кода) +# - OFFER_HASHIDS_MIN_LENGTH: минимальная длина кода +# 6 символов = оптимально для каталогов до 100k коммерческих предложений +# 8 символов = для каталогов 100k-10M коммерческих предложений +# Рекомендация: оставить 6 для начальной фазы, поменять на 8 при росте +OFFER_HASHIDS_SALT=your-super-secret-salt-for-production-change-me-now +OFFER_HASHIDS_MIN_LENGTH=6 + # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) DJANGO_ADMIN_URL=admin/ diff --git a/lpon_site/frontend/models.py b/lpon_site/frontend/models.py index ed3b040..70e4c52 100644 --- a/lpon_site/frontend/models.py +++ b/lpon_site/frontend/models.py @@ -240,8 +240,10 @@ # - M2M использует числовые FK (INT) вместо строк # - Slug'и как UNIQUE indexed fields (не primary_key) для экономии места +import base64 import datetime import logging +from hashids import Hashids from django.db import models from django.db.models import F from filer.fields.image import FilerImageField @@ -257,6 +259,8 @@ from lpon_site.settings import ( KEY_IMAGE_NOTE, # Ключ, для заметок о картинке (например, "обложка", "задник", "вкладка" и т.д.) KEY_OFFER_NOTE, # Ключ, для заметок о коммерческом предложении (например, "замят угол конверта" и т.д.) KEY_OFFER_ALL_MEDIA, # Ключ, для хранения информации о том, какие носители входят в оффер (например, CD+2LP) + OFFER_HASHIDS_SALT, # Соль для криптографического кодирования ID оферов + OFFER_HASHIDS_MIN_LENGTH, # Минимальная длина кода ) logger = logging.getLogger(__name__) @@ -1341,6 +1345,55 @@ class TbOffer(models.Model): i_offer_favorites=F('i_offer_favorites') + 1 ) + def save(self, *args, **kwargs): + """ + Переопределенный метод save для автоматического формирования s_offer_skip32. + + Уникальный код товара формируется в двух случаях: + 1. Для новых оферов (при первом сохранении) + 2. Для старых оферов БЕЗ s_offer_skip32 (миграция существующих данных) + + Использует криптографическое хеширование (hashids): + - Невозможно восстановить исходный ID из кода + - Даже администратор, зная алгоритм, не может нарушить безопасность + - Коды выглядят случайно и не идут подряд + - Компактные (6-12 символов, настраивается в settings) + + Примеры: ID 1 → "q1am12", ID 42 → "QBErd8", ID 1000 → "kd8GgB", ID 9999 → "1GYeRd" + + ЛОГИКА: + - Если это новый оффер (self.pk == None): + 1. super().save() → Django создает запись в БД и присваивает self.pk + 2. Кодируем self.pk в хеш и сохраняем еще раз + + - Если это старый оффер БЕЗ s_offer_skip32 (миграция): + 1. Кодируем существующий self.pk в хеш + 2. Сохраняем с обновленным s_offer_skip32 + + - Если это старый оффер С s_offer_skip32: + 1. Сохраняем как обычно, не трогаем s_offer_skip32 + """ + # Проверяем нужно ли генерировать s_offer_skip32: + # - ИЛИ это новый объект (self.pk == None) + # - ИЛИ это старый объект без s_offer_skip32 (миграция) + if not self.pk or not self.s_offer_skip32: + # Если это новый оффер, сначала сохраняем, чтобы получить ID + if not self.pk: + # Сохраняем БЕЗ s_offer_skip32 чтобы 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) + + # Сохраняем только поле s_offer_skip32 (не перезаписываем остальное) + super().save(update_fields=['s_offer_skip32']) + else: + # Оффер существует И уже имеет s_offer_skip32: сохраняем как обычно + # Не трогаем s_offer_skip32, он был сформирован при создании + super().save(*args, **kwargs) + class Meta: verbose_name = 'Оффер (предложение)' verbose_name_plural = 'Офферы (предложения)' diff --git a/lpon_site/lpon_site/settings.py b/lpon_site/lpon_site/settings.py index 1227362..f3c8888 100644 --- a/lpon_site/lpon_site/settings.py +++ b/lpon_site/lpon_site/settings.py @@ -48,6 +48,13 @@ ALLOWED_HOSTS = env.list( CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=['127.0.0.1', 'localhost', 'testserver']) +# ====== HASHIDS настройка для s_offer_skip32 ====== +# Криптографическое кодирование ID оферов в компактные, необратимые коды. +# Благодаря hashids невозможно восстановить исходный ID из кода, +# даже зная алгоритм (в отличие от простого XOR). +OFFER_HASHIDS_SALT = env('OFFER_HASHIDS_SALT', default='your-secret-salt-change-me') +OFFER_HASHIDS_MIN_LENGTH = env.int('OFFER_HASHIDS_MIN_LENGTH', default=6) + ######################################### # Настройки сообщений об ошибках когда все упало и т.п. ADMINS = tuple( diff --git a/poetry.lock b/poetry.lock index 86d34ae..009a09a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -314,6 +314,20 @@ beautifulsoup4 = ">=4.10.0" lxml = ">=4.9.0" regex = ">=2022.1.18" +[[package]] +name = "hashids" +version = "1.3.1" +description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/" +optional = false +python-versions = ">=2.7" +files = [ + {file = "hashids-1.3.1-py2.py3-none-any.whl", hash = "sha256:8bddd1acba501bfc9306e7e5a99a1667f4f2cacdc20cbd70bcc5ddfa5147c94c"}, + {file = "hashids-1.3.1.tar.gz", hash = "sha256:6c3dc775e65efc2ce2c157a65acb776d634cb814598f406469abef00ae3f635c"}, +] + +[package.extras] +test = ["pytest (>=2.1.0)"] + [[package]] name = "lxml" version = "6.1.1" @@ -903,4 +917,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "69bad093bc7511b8da08bb4673d5883f7cbeb9b45123b6a17350dd885e2a3a33" +content-hash = "df0a39ef96e82584ad911b561106eeb7559db3c6e78fb0c3a8b53a80738620b9" diff --git a/pyproject.toml b/pyproject.toml index ea909be..41d6f94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ django-filer = "^3.4.4" pillow-heif = "^1.3.0" pytils = "^0.4.4" etpgrf = "^0.1.6.post1" +hashids = "^1.3.1" [tool.poetry.group.dev.dependencies] django-extensions = "^3.2"