diff --git a/hypn0/hypn0/settings.py b/hypn0/hypn0/settings.py index 0104cd5..7f201f6 100644 --- a/hypn0/hypn0/settings.py +++ b/hypn0/hypn0/settings.py @@ -209,6 +209,7 @@ LVL_LOCK_FOR_DELETION = 1000 # ====== Направление и веса для голосования (ТОЛЛЬКО ЦЕЛЫЕ ЧИСЛА) ====== VOTE_LIKE = 1 VOTE_CLAIM = -2 +VOTE_AUTHOR = 2 # ====== Гамма для вычисления рейтинга ====== GAMMA = 1.5 diff --git a/hypn0/hypn0_site/migrations/0001_initial.py b/hypn0/hypn0_site/migrations/0001_initial.py new file mode 100644 index 0000000..c0b9395 --- /dev/null +++ b/hypn0/hypn0_site/migrations/0001_initial.py @@ -0,0 +1,79 @@ +# Generated by Django 6.1 on 2026-08-22 11:14 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TbBlogPost', + fields=[ + ('id', models.SmallAutoField(primary_key=True, serialize=False, verbose_name='ID')), + ('s_title', models.CharField(help_text='HTML-заголовок (с HTML-тегами, типографированный etpgrf)', max_length=255, verbose_name='Заголовок')), + ('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-слаг')), + ('s_teaser', models.TextField(blank=True, help_text='HTML-контент тизера, очищенный и типографированный etpgrf', max_length=1024, verbose_name='Краткое описание / Лид')), + ('s_content', models.TextField(help_text='HTML-контент статьи, очищенный и типографированный etpgrf', verbose_name='Основной HTML-контент')), + ('f_cover_img', models.ImageField(blank=True, null=True, upload_to='blog/covers/%Y/', verbose_name='Обложка')), + ('is_published', models.BooleanField(default=False, verbose_name='Опубликовано')), + ('d_published_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Дата публикации')), + ('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('d_updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Статья блога / Страница', + 'verbose_name_plural': 'Статьи блога / Страницы', + 'ordering': ['-d_published_at', '-d_created_at'], + }, + ), + migrations.CreateModel( + name='TbHypn0Item', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID')), + ('s_hash_id', models.CharField(max_length=16, unique=True, verbose_name='ID-Хэш')), + ('s_title', models.CharField(help_text='Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.', max_length=255, verbose_name='Название генерации')), + ('file_svg', models.FileField(help_text='Результирующий SVG-файл генерации', upload_to='svg/%Y/%m/', verbose_name='SVG-файл')), + ('i_file_size', models.PositiveIntegerField(default=0, help_text='Размер файла в байтах', verbose_name='Размер файла')), + ('j_metadata', models.JSONField(blank=True, default=dict, help_text='Метаданные и параметры для возможного клонирования настроек', verbose_name='Параметры генерации')), + ('i_likes_count', models.PositiveIntegerField(db_index=True, default=0, verbose_name='Число лайков')), + ('i_views_count', models.PositiveIntegerField(default=0, verbose_name='Число просмотров')), + ('i_claims_count', models.PositiveIntegerField(default=0, verbose_name='Число жалоб')), + ('f_score', models.FloatField(db_index=True, default=0.0, help_text='Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять', verbose_name='Рейтинг популярности')), + ('i_level', models.IntegerField(choices=[(0, 'Candidate: Шум сознания'), (10, 'Voted: Плеск бессознательного'), (30, 'Moderated: Одобрено Мозговым Слизнем'), (1000, 'Locked: Глубокий транс')], default=0, help_text='Уровень', verbose_name='В какой группе находится SVG-файл генерации')), + ('is_public', models.BooleanField(default=True, help_text='Доступность по прямой ссылке', verbose_name='Публичный доступ')), + ('s_promo_url', models.URLField(blank=True, default='', verbose_name='Промо-ссылка')), + ('s_promo_title', models.CharField(blank=True, default='', max_length=100, verbose_name='Бейдж/автор промо')), + ('i_promo_clicks', models.PositiveIntegerField(default=0, verbose_name='Клики по промо')), + ('d_created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата создания')), + ('d_updated_at', models.DateTimeField(auto_now=True, db_index=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Гипно-картина', + 'verbose_name_plural': 'Гипно-картины', + 'ordering': ['-d_created_at'], + }, + ), + migrations.CreateModel( + name='TbVote', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False, verbose_name='ID')), + ('i_direction', models.IntegerField(choices=[(1, 'Like (+1)'), (-2, 'Clime (-2)'), (2, 'Author Like (+2)')], default=1, help_text='Направление голоса', verbose_name='Направление')), + ('s_fingerprint', models.CharField(db_index=True, help_text='SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств', max_length=64, verbose_name='Хэш устройства/отпечаток')), + ('d_created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата голосования')), + ('k_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='hypn0_site.tbhypn0item', verbose_name='Картина')), + ], + options={ + 'verbose_name': 'Голос / Лайк', + 'verbose_name_plural': 'Голоса / Лайки', + 'ordering': ['-d_created_at'], + 'constraints': [models.UniqueConstraint(fields=('k_item', 's_fingerprint'), name='unique_item_fingerprint_vote')], + }, + ), + ] diff --git a/hypn0/hypn0_site/models.py b/hypn0/hypn0_site/models.py index a1e5fc4..e894c6a 100644 --- a/hypn0/hypn0_site/models.py +++ b/hypn0/hypn0_site/models.py @@ -1,35 +1,35 @@ from django.db import models +from django.db.models import F from hashids import Hashids from hypn0.settings import * from django.utils import timezone class TbHypn0Item(models.Model): """ - МодельЕдиная модель для генераций, публичных шеров и витрины галереи. + Единая модель для генераций, публичных шеров и витрины галереи. Поля: - • id: идентификатор записи (стандартное поле Django) - • s_hash_id: хэш-сумма шеринга для SVG-генерации + • id: идентификатор записи (AutoField, 4 байта, до ~2.14 млрд) + • s_hash_id: хэш-сумма шеринга для SVG-генерации (Hashids) • s_title: Заголовок (до 255 символов, разрешен HTML) • file_svg: SVG-файл генерации - • i_sha1: контрольная сумма (SHA1-хэш файла) - • i_file_size: размер файла - • j_metadata: метаданные файла JSON (например, для хранения параметров генерации) + • i_file_size: размер файла в байтах + • j_metadata: метаданные файла JSON (параметры алгоритма генерации) • i_likes_count: количество лайков • i_views_count: количество просмотров - • i_claims_count: количество жалоб/претензий (дислайков) + • i_claims_count: количество жалоб/претензий (клеймов) • f_score: оценка для стратегии хранения и умной очистки (Smart Retention) • i_level: уровень хранения (свежее, модерированное, защищенное от удаления) - • is_public: публичный (доступный по внешней ссылке) - • s_author_fingerprint: хеш-код браузера автора - • s_promo_url: промо-ссылка (с дополнительной об авторе-спонсоре) + • is_public: доступность по внешней ссылке + • s_promo_url: промо-ссылка спонсора/автора • s_promo_title: заголовок промо-ссылки или бейджа - • i_promo_clicks: количество кликов по промо-ссылке + • i_promo_clicks: количество переходов по промо-ссылке • d_created_at: дата создания записи • d_updated_at: дата последнего обновления записи Методы: • save(): формирование s_hash_id + • rescore(): пересчёт рейтинга f_score """ class Level(models.IntegerChoices): CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней. @@ -37,10 +37,14 @@ class TbHypn0Item(models.Model): LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score + id = models.AutoField( + primary_key=True, + verbose_name="ID", + help_text="Первичный ключ (~2.14 млрд записей, 4 байта)", + ) s_hash_id = models.CharField( max_length=16, unique=True, - db_index=True, verbose_name="ID-Хэш", ) s_title = models.CharField( @@ -53,18 +57,13 @@ class TbHypn0Item(models.Model): 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, verbose_name="Размер файла", help_text="Размер файла в байтах", ) - # НА ВСЯИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек + # НА ВСЯКИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек j_metadata = models.JSONField( default=dict, blank=True, @@ -106,14 +105,6 @@ class TbHypn0Item(models.Model): verbose_name="Публичный доступ", ) - # Автор - s_author_fingerprint = models.CharField( - max_length=64, - db_index=True, # ??? -- подумать нужно или нет - help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств", - verbose_name="Хэш устройства/отпечаток", - ) - # Промо-блок (на перспективу) s_promo_url = models.URLField( blank=True, @@ -140,7 +131,7 @@ class TbHypn0Item(models.Model): auto_now=True, db_index=True, verbose_name="Дата обновления", - ) + ) class Meta: verbose_name = "Гипно-картина" @@ -169,13 +160,13 @@ class TbHypn0Item(models.Model): TbHypn0Item.objects.get(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1) def rescore(self): - """Пересчет рейтинга i_promo_clicks + r"""Пересчет рейтинга популярности картины Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности - («гравитации»), а не слепой Х-дневный таймер: + («гравитации»), а не слепой таймер: $$\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) + 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) def save(self, *args, **kwargs): @@ -205,26 +196,33 @@ class TbHypn0Item(models.Model): # Сохраняем только поле s_hash_id (не перезаписываем остальное) super().save(update_fields=['s_hash_id']) else: - # Оффер существует И уже имеет s_hash_id: сохраняем как обычно + # Объект существует И уже имеет s_hash_id: сохраняем как обычно # Не трогаем s_hash_id, он был сформирован при создании super().save(*args, **kwargs) class TbVote(models.Model): """ - Анонимный учет голосов (лайков) без сохранения ПДн. + Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн. Поля: + • id: первичный ключ записи (BigAutoField, 8 байт, до ~9×10¹⁸) • k_item: ключ объекта (foreign key), за который голосуют - • i_direction: направление голоса - • s_fingerprint: Хэш-отпечаток клиент/браузер/secret для защиты он накрутки - • d_created_at: дата создания + • i_direction: направление голоса (лайк, жалоба или голос автора) + • s_fingerprint: хэш-отпечаток клиент/браузер/секрет для защиты от накрутки + • d_created_at: дата и время фиксации голоса """ class Direction(models.IntegerChoices): - LIKE = VOTE_LIKE, 'Like' - CLIME = VOTE_CLAIM, 'Clime' + LIKE = VOTE_LIKE, 'Like (+1)' + CLAIM = VOTE_CLAIM, 'Claim (-2)' + AUTHOR = VOTE_AUTHOR, 'Author Like (+2)' + id = models.BigAutoField( + primary_key=True, + verbose_name="ID", + help_text="Первичный ключ (BigAutoField, 8 байт)", + ) k_item = models.ForeignKey( TbHypn0Item, on_delete=models.CASCADE, @@ -239,9 +237,9 @@ class TbVote(models.Model): ) s_fingerprint = models.CharField( max_length=64, - db_index=True, # ??? -- подумать нужно или нет - help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств", + db_index=True, verbose_name="Хэш устройства/отпечаток", + help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств", ) d_created_at = models.DateTimeField( auto_now_add=True, @@ -268,19 +266,22 @@ class TbBlogPost(models.Model): Статьи блога, документация и инфо-страницы (Privacy Policy и др.). Поля: - • 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 + • id: первичный ключ статьи (SmallAutoField, 2 байта, до 32 767 записей) + • 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: дата обновления записи """ - + id = models.SmallAutoField( + primary_key=True, + verbose_name="ID", + help_text="Первичный ключ (SmallAutoField, до 32 767 записей)", + ) s_title = models.CharField( max_length=255, help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",