mod: модели (10) метод save() модели TbHypn0Item

This commit is contained in:
2026-08-23 16:51:00 +03:00
parent b63dbb6bd2
commit 27049e9ae0
2 changed files with 59 additions and 48 deletions
-14
View File
@@ -200,20 +200,6 @@ STORAGES = {
} }
# СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ # СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ
# ====== Уровни для SVG-генераций (используется для чисти файлов при достижении лимитов) ======
LVL_CANDIDATE = 0
LVL_PRE_MODERATED = 10
LVL_MODERATED = 30
LVL_LOCK_FOR_DELETION = 1000
# ====== Направление и веса для голосования (ТОЛЛЬКО ЦЕЛЫЕ ЧИСЛА) ======
VOTE_LIKE = 1
VOTE_CLAIM = -2
VOTE_AUTHOR = 2
# ====== Гамма для вычисления рейтинга ======
GAMMA = 1.5
# ====== HASHIDS настройка для s_hash_id ====== # ====== HASHIDS настройка для s_hash_id ======
# Криптографическое кодирование ID svg-файлов генерации в компактные, необратимые коды. # Криптографическое кодирование ID svg-файлов генерации в компактные, необратимые коды.
# Благодаря hashids невозможно восстановить исходный ID из кода, # Благодаря hashids невозможно восстановить исходный ID из кода,
+58 -33
View File
@@ -33,10 +33,10 @@ class TbHypn0Item(models.Model):
• rescore(): пересчёт рейтинга f_score • rescore(): пересчёт рейтинга f_score
""" """
class Level(models.IntegerChoices): class Level(models.IntegerChoices):
CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней. CANDIDATE = 0, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
LEVEL_1 = LVL_PRE_MODERATED, 'Voted: Плеск бессознательного' # Прогрето, есть "лайки", не проверено модератором, можно удалять при низком score LEVEL_1 = 10, 'Voted: Плеск бессознательного' # Прогрето, есть "лайки", не проверено модератором, можно удалять при низком score
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score LEVEL_2 = 30, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score IMMORTAL = 1000, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score
# Отрицательные / проблемные уровни (аномалии) # Отрицательные / проблемные уровни (аномалии)
SHAMED = -20, 'Шейминг (аномальный наплыв жалоб)' SHAMED = -20, 'Шейминг (аномальный наплыв жалоб)'
SUSPICIOUS = -10, 'Подозрение на накрутку (спайк лайков)' SUSPICIOUS = -10, 'Подозрение на накрутку (спайк лайков)'
@@ -49,6 +49,7 @@ class TbHypn0Item(models.Model):
s_hash_id = models.CharField( s_hash_id = models.CharField(
max_length=16, max_length=16,
unique=True, unique=True,
editable=False,
verbose_name="ID-Хэш", verbose_name="ID-Хэш",
) )
s_title = models.CharField( s_title = models.CharField(
@@ -77,7 +78,7 @@ class TbHypn0Item(models.Model):
# Популярность # Популярность
i_likes_count = models.PositiveIntegerField( i_likes_count = models.PositiveIntegerField(
default=0, default=1,
db_index=True, db_index=True,
verbose_name="Число лайков", verbose_name="Число лайков",
help_text="Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета" help_text="Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета"
@@ -85,7 +86,7 @@ class TbHypn0Item(models.Model):
" <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.", " <tt>Голоса/Лайка</tt> с учетом весов лайков и клаймов.",
) )
i_views_count = models.PositiveIntegerField( i_views_count = models.PositiveIntegerField(
default=0, default=1,
verbose_name="Число просмотров", verbose_name="Число просмотров",
) )
i_claims_count = models.PositiveIntegerField( i_claims_count = models.PositiveIntegerField(
@@ -155,7 +156,7 @@ class TbHypn0Item(models.Model):
"""Безопасный инкремент просмотров""" """Безопасный инкремент просмотров"""
TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1) TbHypn0Item.objects.filter(id=self.id).update(i_views_count=F('i_views_count') + 1)
def vote(self, visitor_uuid_or_fp: str, direction: int = VOTE_LIKE) -> bool: def vote(self, visitor_uuid_or_fp: str, direction: int | None = None) -> bool:
""" """
Универсальный метод учета голоса (лайк, жалоба или авторство). Универсальный метод учета голоса (лайк, жалоба или авторство).
@@ -170,6 +171,9 @@ class TbHypn0Item(models.Model):
if not visitor_uuid_or_fp: if not visitor_uuid_or_fp:
return False return False
if direction is None:
direction = TbVote.Direction.LIKE
# Если передан сырой UUID устройства, вычисляем SHA256 отпечаток # Если передан сырой UUID устройства, вычисляем SHA256 отпечаток
if len(visitor_uuid_or_fp) == 64: if len(visitor_uuid_or_fp) == 64:
fingerprint = visitor_uuid_or_fp fingerprint = visitor_uuid_or_fp
@@ -214,37 +218,58 @@ class TbHypn0Item(models.Model):
"""Безопасный инкремент кликов по промо""" """Безопасный инкремент кликов по промо"""
TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1) TbHypn0Item.objects.filter(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1)
def save(self, *args, **kwargs): def save(self, *args, visitor_uuid_or_fp: str | None = None, **kwargs):
""" """
Переопределенный метод save для: Переопределенный метод save для TbHypn0Item.
1. Автоматического формирования s_hash_id (криптографический код)
ЛОГИКА s_hash_id: ПРАВИЛА И СЦЕНАРИИ СОХРАНЕНИЯ:
- Для новых генераций: создаем код после получения ID ------------------------------
- Для старых генераций БЕЗ кода: кодируем существующий ID (миграция) 1. Создание новой картины (is_new=True):
- Для старых генераций С кодом: не трогаем - Создание возможно ТОЛЬКО с фронтенда при наличии согласия на отслеживание (visitor_uuid_or_fp).
- Если пользователь не "подчинился Гипножабе" (visitor_uuid_or_fp отсутствует), создание БЛОКИРУЕТСЯ
выбрасыванием PermissionError (защита от сохранения анонимных работ без автора).
- В рамках одной атомарной транзакции:
а) Сохраняется запись картины и генерируется self.pk.
б) Генерируется криптографический s_hash_id.
в) Создается обязательная авторская запись TbVote(Direction.AUTHOR).
г) Стартовый счетчик i_likes_count по умолчанию равен 1.
2. Редактирование существующей картины (is_new=False, например из Django Admin):
- Картина уже существует в БД (self.pk есть).
- Разрешено обычное обновление любых полей (название, уровень модерации, рейтинг и т.д.).
- Никакие авторские записи повторно не создаются.
""" """
# 1. Проверяем нужно ли генерировать s_hash_id: is_new = self.pk is None
# - ИЛИ это новый объект (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 в компактный, необратимый код # 0. Блокировка создания новой картины без подчинения Гипножабе
# Пример: pk=42 → "QBErd8" if is_new and not visitor_uuid_or_fp:
self.s_hash_id = Hashids(salt=HASHIDS_SALT, min_length=HASHIDS_MIN_LENGTH).encode(self.pk) raise PermissionError("Создание картины запрещено: пользователь не подчинился Гипножабе (отсутствует visitor UUID)")
# Сохраняем только поле s_hash_id (не перезаписываем остальное) # 1. Вычисляем хэш автора для новой картины
super().save(update_fields=['s_hash_id']) author_fp = None
if is_new and visitor_uuid_or_fp:
if len(visitor_uuid_or_fp) == 64:
author_fp = visitor_uuid_or_fp
else: else:
# Объект существует И уже имеет s_hash_id: сохраняем как обычно author_fp = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest()
# Не трогаем s_hash_id, он был сформирован при создании
with transaction.atomic():
# 2. Сохраняем объект в базу (для новых записей СУБД присваивает self.pk)
super().save(*args, **kwargs) super().save(*args, **kwargs)
# 3. Формируем s_hash_id, если его еще нет
if not self.s_hash_id:
self.s_hash_id = Hashids(salt=HASHIDS_SALT, min_length=HASHIDS_MIN_LENGTH).encode(self.pk)
super().save(update_fields=['s_hash_id'])
# 4. Регистрируем автора в TbVote (гарантированно после получения self.pk)
if is_new and author_fp:
TbVote.objects.create(
k_item=self,
i_direction=TbVote.Direction.AUTHOR,
s_fingerprint=author_fp,
)
class TbVote(models.Model): class TbVote(models.Model):
""" """
@@ -259,9 +284,9 @@ class TbVote(models.Model):
""" """
class Direction(models.IntegerChoices): class Direction(models.IntegerChoices):
LIKE = VOTE_LIKE, 'Like (+1)' LIKE = 1, 'Like (+1)'
CLAIM = VOTE_CLAIM, 'Claim (-2)' CLAIM = -2, 'Claim (-2)'
AUTHOR = VOTE_AUTHOR, 'Author Like (+2)' AUTHOR = 2, 'Author Like (+2)'
id = models.BigAutoField( id = models.BigAutoField(
primary_key=True, primary_key=True,