diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index e894c6a..a590087 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -1,4 +1,5 @@ -from django.db import models +import hashlib +from django.db import models, transaction, IntegrityError from django.db.models import F from hashids import Hashids from hypn0.settings import * @@ -76,6 +77,9 @@ class TbHypn0Item(models.Model): default=0, db_index=True, verbose_name="Число лайков", + help_text="Число лайков гипнотической SVG-картины. Поле используется для отображения в интерфейсе. Для расчета" + " рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы" + " Голоса/Лайка с учетом весов лайков и клаймов.", ) i_views_count = models.PositiveIntegerField( default=0, @@ -84,6 +88,9 @@ class TbHypn0Item(models.Model): i_claims_count = models.PositiveIntegerField( default=0, verbose_name="Число жалоб", + help_text="Число жалоб на гипнотическую SVG-картину. Поле используется для отображения в интерфейсе. Для расчета" + " рейтинга популярности намеренно НЕ ИСПОЛЬЗУЕТСЯ. Рейтинг будет рассчитан на основе таблицы" + " Голоса/Лайка с учетом весов лайков и клаймов.", ) f_score = models.FloatField( default=0.0, @@ -143,21 +150,66 @@ class TbHypn0Item(models.Model): def increment_views(self): """Безопасный инкремент просмотров""" - TbHypn0Item.objects.get(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 increment_likes(self): - """Безопасный инкремент лайков""" - TbHypn0Item.objects.get(id=self.id).update(i_likes_count=F('i_likes_count') + 1) - # Здесь будет код для записи в TbVote + def vote(self, visitor_uuid_or_fp: str, direction: int = VOTE_LIKE) -> bool: + """ + Универсальный метод учета голоса (лайк, жалоба или авторство). - def increment_claims(self): - """Безопасный инкремент жалоб""" - TbHypn0Item.objects.get(id=self.id).update(i_claims_count=F('i_claims_count') + 1) - # Здесь будет код для записи в TbVote + Принимает: + - visitor_uuid_or_fp: UUIDv4 из куки hypn0_vid или готовый 64-символьный SHA256-хэш отпечатка + - direction: направление голоса (Direction.LIKE, Direction.CLAIM, Direction.AUTHOR) + + Возвращает: + - True: голос успешно зафиксирован и счетчик увеличен + - False: голос уже существовал (защита от повторного голосования) или передан пустой токен + """ + if not visitor_uuid_or_fp: + return False + + # Если передан сырой UUID устройства, вычисляем SHA256 отпечаток + if len(visitor_uuid_or_fp) == 64: + fingerprint = visitor_uuid_or_fp + else: + fingerprint = hashlib.sha256(f"{visitor_uuid_or_fp}:{SECRET_KEY}".encode()).hexdigest() + + try: + # В SQLite при возникновении IntegrityError текущая транзакция может перейти в состояние ошибки. Чтобы это + # не затрагивало последующие операции с базой, try-записи изолирована в `with transaction.atomic()` + with transaction.atomic(): + TbVote.objects.create( + k_item=self, + i_direction=direction, + s_fingerprint=fingerprint, + ) + except IntegrityError: + # Нарушение UniqueConstraint (k_item, s_fingerprint) — голос с этого устройства уже учтен + return False + + # Атомарный инкремент нужного счетчика в зависимости от направления + # Используем `objects.filter()` вместо `objects.get()`, так как метод .update() вызывается только у QuerySet. + if direction in (TbVote.Direction.LIKE, TbVote.Direction.AUTHOR): + TbHypn0Item.objects.filter(id=self.id).update(i_likes_count=F('i_likes_count') + 1) + elif direction == TbVote.Direction.CLAIM: + TbHypn0Item.objects.filter(id=self.id).update(i_claims_count=F('i_claims_count') + 1) + + # Рейтинг популярности f_score намеренно НЕ пересчитывается на лету при каждом лайке: + # 1. f_score зависит от времени (возраста картины), поэтому непрерывно меняется сам по себе. + # 2. Значение f_score необходимо только периодическому процессу умной очистки диска (Smart Retention). + # 3. Пересчет вынесен в пакетную cron-задачу перед ротацией хранилища, что исключает лишние I/O-операции в SQLite. + return True + + def increment_likes(self, visitor_uuid_or_fp: str) -> bool: + """Безопасный инкремент лайков с фиксацией в TbVote""" + return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.LIKE) + + def increment_claims(self, visitor_uuid_or_fp: str) -> bool: + """Безопасный инкремент жалоб с фиксацией в TbVote""" + return self.vote(visitor_uuid_or_fp, direction=TbVote.Direction.CLAIM) def increment_promo_clicks(self): """Безопасный инкремент кликов по промо""" - TbHypn0Item.objects.get(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 rescore(self): r"""Пересчет рейтинга популярности картины @@ -167,7 +219,7 @@ class TbHypn0Item(models.Model): """ age_in_hours = (timezone.now() - self.d_created_at).total_seconds() / 3600 + 1 new_score = (self.i_likes_count + self.i_level) / ((age_in_hours + 2) ** GAMMA) - TbHypn0Item.objects.get(id=self.id).update(f_score=new_score) + TbHypn0Item.objects.filter(id=self.id).update(f_score=new_score) def save(self, *args, **kwargs): """ @@ -247,8 +299,8 @@ class TbVote(models.Model): ) class Meta: - verbose_name = "Голос / Лайк" - verbose_name_plural = "Голоса / Лайки" + verbose_name = "Голос/Лайк" + verbose_name_plural = "Голоса/Лайки" constraints = [ models.UniqueConstraint( fields=["k_item", "s_fingerprint"],