diff --git a/.env.sample b/.env.sample index 997a2cb..70baf78 100644 --- a/.env.sample +++ b/.env.sample @@ -17,6 +17,19 @@ DJANGO_EMAIL_HOST_USER=you@email.com DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME DJANGO_EMAIL_FROM=you@email.com +# ====== HASHIDS настройка для TbHypn0Item.s_hash_id ====== +# Используется для кодирования ID генераций SVG в компактные, хаотичные коды (не криптографический хеш, а encoder). +# - HASHIDS_SALT: секретная соль для кодирования (ВАЖНО! Менять для production!) +# Только с солью можно декодировать, без нее невозможно восстановить ID +# Генерируйте криптографически стойкую случайную строку: python -c "import secrets; print(secrets.token_hex(16))" +# Содержит только буквы и цифры (безопасно для URL и QR-кода) +# - HASHIDS_MIN_LENGTH: минимальная длина кода +# 6 символов = оптимально для каталогов до 100k +# 8 символов = для каталогов 100k-10M +# Рекомендация: оставить 6 для начальной фазы, поменять на 8 при росте +HASHIDS_SALT=your-super-secret-salt-for-production-change-me-now +HASHIDS_MIN_LENGTH=6 + # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) ADMIN_URL=admin/ diff --git a/hypn0/hypn0/settings.py b/hypn0/hypn0/settings.py index eac7cdd..a126cdf 100644 --- a/hypn0/hypn0/settings.py +++ b/hypn0/hypn0/settings.py @@ -199,11 +199,22 @@ STORAGES = { }, } -# Специфичные для проекта настройки -LVL_CANDIDATE = 10 -LVL_PRE_MODERATED = 20 +# СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ +# ====== Уровни для SVG-генераций (используется для чисти файлов при достижении лимитов) ====== +LVL_CANDIDATE = 0 +LVL_PRE_MODERATED = 10 LVL_MODERATED = 30 -LVL_LOCK_FOR_DELETION = 40 +LVL_LOCK_FOR_DELETION = 1000 + +# ====== Гамма для вычисления рейтинга ====== +GAMMA = 1.5 + +# ====== HASHIDS настройка для s_hash_id ====== +# Криптографическое кодирование ID svg-файлов генерации в компактные, необратимые коды. +# Благодаря hashids невозможно восстановить исходный ID из кода, +# даже зная алгоритм (в отличие от простого XOR). +HASHIDS_SALT = env('HASHIDS_SALT', default='your-secret-salt-change-me') +HASHIDS_MIN_LENGTH = env.int('HASHIDS_MIN_LENGTH', default=6) if DEBUG: diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index 123b101..ea3f774 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -1,40 +1,70 @@ from django.db import models +from hashids import Hashids from hypn0.settings import * +from django.utils import timezone class TbHypn0Item(models.Model): - """Единая модель для генераций, публичных шеров и витрины галереи.""" + """ + МодельЕдиная модель для генераций, публичных шеров и витрины галереи. + + Поля: + • id: идентификатор записи (стандартное поле Django) + • s_hash_id: хэш-сумма шеринга для SVG-генерации + • s_title: Заголовок (до 255 символов, разрешен HTML) + • file_svg: SVG-файл генерации + • i_sha1: контрольная сумма (SHA1-хэш файла) + • i_file_size: размер файла + • j_metadata: метаданные файла JSON (например, для хранения параметров генерации) + • i_likes_count: количество лайков + • i_views_count: количество просмотров + • i_claims_count: количество жалоб/претензий (дислайков) + • f_score: оценка для стратегии хранения и умной очистки (Smart Retention) + • i_level: уровень хранения (свежее, модерированное, защищенное от удаления) + • is_public: публичный (доступный по внешней ссылке) + • s_promo_url: промо-ссылка (с дополнительной об авторе-спонсоре) + • s_promo_title: заголовок промо-ссылки или бейджа + • i_promo_clicks: количество кликов по промо-ссылке + • d_created_at: дата создания записи + • d_updated_at: дата последнего обновления записи + + Методы: + • save(): формирование s_hash_id + """ class Level(models.IntegerChoices): - CANDIDATE = LVL_CANDIDATE, 'Шум сознания' # Свежая генерация. Запрет на удаление до Х дней. - LEVEL_1 = LVL_PRE_MODERATED, 'Плеск бессознательного' # Есть "лайки", не проверено, можно удалять при низком score - LEVEL_2 = LVL_MODERATED, 'Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score - IMMORTAL = LVL_LOCK_FOR_DELETION, 'Глубокий транс' # Нельзя удалять, даже при низком score + CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней. + LEVEL_1 = LVL_PRE_MODERATED, 'Voted: Плеск бессознательного' # Есть "лайки", не проверено, можно удалять при низком score + LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score + IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score s_hash_id = models.CharField( max_length=16, unique=True, db_index=True, - verbose_name="Хэш-идентификатор", + verbose_name="ID-Хэш", ) s_title = models.CharField( max_length=255, - verbose_name="Название картины", + verbose_name="Название генерации", + help_text="Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.", ) file_svg = models.FileField( upload_to="svg/%Y/%m/", verbose_name="SVG-файл", + help_text="Результирующий SVG-файл генерации", ) i_sha1 = models.CharField( max_length=40, verbose_name="SHA1", + help_text="SHA1 хэш результирующего SVG-файла", ) i_file_size = models.PositiveIntegerField( default=0, - help_text="Размер файла в байтах", verbose_name="Размер файла", + help_text="Размер файла в байтах", ) - # Снимок параметров для возможного клонирования настроек - j_params = models.JSONField( + # НА ВСЯИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек + j_metadata = models.JSONField( default=dict, blank=True, verbose_name="Параметры генерации", @@ -45,29 +75,29 @@ class TbHypn0Item(models.Model): i_likes_count = models.PositiveIntegerField( default=0, db_index=True, - verbose_name="Количество лайков", + verbose_name="Число лайков", ) i_views_count = models.PositiveIntegerField( default=0, - verbose_name="Количество просмотров", + verbose_name="Число просмотров", ) - i_reports_count = models.PositiveIntegerField( + i_claims_count = models.PositiveIntegerField( default=0, - verbose_name="Количество жалоб", + verbose_name="Число жалоб", ) f_score = models.FloatField( default=0.0, db_index=True, - help_text="Рейтинг для Smart Retention", verbose_name="Рейтинг популярности", + help_text="Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять", ) # Модерация i_level = models.IntegerField( choices=Level.choices, default=Level.CANDIDATE, - help_text="Уровень доступа", - verbose_name="Уровень доступа", + help_text="Уровень", + verbose_name="В какой группе находится SVG-файл генерации", ) is_public = models.BooleanField( default=True, @@ -111,9 +141,73 @@ class TbHypn0Item(models.Model): def __str__(self) -> str: return f"{self.s_title} ({self.s_hash_id})" + def increment_views(self): + """Безопасный инкремент просмотров""" + TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1) + + def increment_likes(self): + """Безопасный инкремент лайков""" + TbHypn0Item.objects.filter(id=self.id).update(i_likes_count=F('i_likes_count') + 1) + + def increment_claims(self): + """Безопасный инкремент жалоб""" + TbHypn0Item.objects.filter(id=self.id).update(i_claims_count=F('i_claims_count') + 1) + + def increment_promo_clicks(self): + """Безопасный инкремент кликов по промо""" + TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1) + + def rescore(self): + """Пересчет рейтинга i_promo_clicks + Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности + («гравитации»), а не слепой Х-дневный таймер: + $$\text{Score} = \frac{\text{Likes} + \text{Bonus}_{\text{moderator}}}{(\text{Age in hours} + 2)^\gamma}$$ + """ + age_ih_hours = (timezone.now - self.d_created_at).total_seconds() / 3600 + 1 + new_score = (self.i_likes_count + self.i_level) / ((age_ih_hours + 2) ** GAMMA) + TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=new_score) + + def save(self, *args, **kwargs): + """ + Переопределенный метод save для: + 1. Автоматического формирования s_hash_id (криптографический код) + + ЛОГИКА s_hash_id: + - Для новых генераций: создаем код после получения ID + - Для старых генераций БЕЗ кода: кодируем существующий ID (миграция) + - Для старых генераций С кодом: не трогаем + """ + # 1. Проверяем нужно ли генерировать s_hash_id: + # - ИЛИ это новый объект (self.pk == None) + # - ИЛИ это старый объект без s_hash_id (миграция) + if not self.pk or not self.s_hash_id: + # Если это новая генерация, сначала сохраняем её, чтобы получить ID + if not self.pk: + # Сохраняем БЕЗ s_hash_id чтобы Django создал запись и присвоил pk + super().save(*args, **kwargs) + # После save() Django автоматически заполнит self.pk + + # Кодируем pk в компактный, необратимый код + # Пример: pk=42 → "QBErd8" + self.s_hash_id = Hashids(salt=HASHIDS_SALT, min_length=HASHIDS_MIN_LENGTH).encode(self.pk) + + # Сохраняем только поле s_hash_id (не перезаписываем остальное) + super().save(update_fields=['s_hash_id']) + else: + # Оффер существует И уже имеет s_hash_id: сохраняем как обычно + # Не трогаем s_hash_id, он был сформирован при создании + super().save(*args, **kwargs) + class TbVote(models.Model): - """Анонимный учет голосов (лайков) без сохранения ПДн.""" + """ + Анонимный учет голосов (лайков) без сохранения ПДн. + + Поля: + • k_item: ключ объекта (foreign key), за который голосуют + • s_fingerprint: Хэш-отпечаток клиент/браузер/secret для защиты он накрутки + • d_created_at: дата создания + """ k_item = models.ForeignKey( TbHypn0Item, @@ -151,12 +245,23 @@ class TbBlogPost(models.Model): """ Статьи блога, документация и инфо-страницы (Privacy Policy и др.). - + виртуальные поля для управления типографом etpgrf + Поля: + • s_title: Заголовок статьи (HTML). + • slug: Слаг (URI-адрес) статьи. + • s_teaser: Тизер статьи (HTML). + • s_content: Текст статьи (HTML). + • f_cover_img: Картинка обложки статьи. + • is_published: Опубликована ли статья. + • d_published_at: Дата публикации статьи. + • d_created_at: Дата создания статьи. + • d_updated_at: Дата обновления статьи. + + + а в админке виртуальные поля для управления типографом etpgrf """ s_title = models.CharField( - max_length=200, - help_text="HTML-заголовок, очищенный и типографированный etpgrf", + max_length=255, + help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)", verbose_name="Заголовок", ) slug = models.SlugField( @@ -164,7 +269,7 @@ class TbBlogPost(models.Model): unique=True, verbose_name="URL-слаг", ) - s_summary = models.TextField( + s_teaser = models.TextField( max_length=1024, blank=True, help_text="HTML-контент тизера, очищенный и типографированный etpgrf", @@ -186,8 +291,7 @@ class TbBlogPost(models.Model): verbose_name="Опубликовано", ) d_published_at = models.DateTimeField( - null=True, - blank=True, + default=timezone.now, db_index=True, verbose_name="Дата публикации", ) @@ -195,7 +299,7 @@ class TbBlogPost(models.Model): auto_now_add=True, verbose_name="Дата создания", ) - d_created_at = models.DateTimeField( + d_updated_at = models.DateTimeField( auto_now=True, verbose_name="Дата обновления", ) diff --git a/poetry.lock b/poetry.lock index 0b33168..c7bf47c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -93,6 +93,21 @@ setproctitle = ["setproctitle"] testing = ["coverage", "gevent (>=24.10.1)", "h2 (>=4.1.0)", "httpx[http2]", "pytest", "pytest-asyncio", "pytest-cov", "uvloop (>=0.19.0)"] tornado = ["tornado (>=6.5.0)"] +[[package]] +name = "hashids" +version = "1.3.1" +description = "Implements the hashids algorithm in python. For more information, visit http://hashids.org/" +category = "main" +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 = "packaging" version = "26.3" @@ -256,4 +271,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.14" -content-hash = "b9c94317c0624ff0156fd9db6187c2a709b4c0ad5cce5424a745a5091e3816ad" +content-hash = "eecf8236a6668e8b92042c0d9c1e366d6ac175e209258664483f38fa2ec785e1" diff --git a/pyproject.toml b/pyproject.toml index a0c7f0f..b8b69f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ python = "^3.14" pillow = "^12.3.0" django = "^6.0.8" django-environ = "^0.14.0" +hashids = "^1.3.1" gunicorn = "^26.0.0" whitenoise = "^6.12.0"