mod: модели и админка (7) + миграции
This commit is contained in:
@@ -209,6 +209,7 @@ LVL_LOCK_FOR_DELETION = 1000
|
|||||||
# ====== Направление и веса для голосования (ТОЛЛЬКО ЦЕЛЫЕ ЧИСЛА) ======
|
# ====== Направление и веса для голосования (ТОЛЛЬКО ЦЕЛЫЕ ЧИСЛА) ======
|
||||||
VOTE_LIKE = 1
|
VOTE_LIKE = 1
|
||||||
VOTE_CLAIM = -2
|
VOTE_CLAIM = -2
|
||||||
|
VOTE_AUTHOR = 2
|
||||||
|
|
||||||
# ====== Гамма для вычисления рейтинга ======
|
# ====== Гамма для вычисления рейтинга ======
|
||||||
GAMMA = 1.5
|
GAMMA = 1.5
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+53
-52
@@ -1,35 +1,35 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import F
|
||||||
from hashids import Hashids
|
from hashids import Hashids
|
||||||
from hypn0.settings import *
|
from hypn0.settings import *
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
class TbHypn0Item(models.Model):
|
class TbHypn0Item(models.Model):
|
||||||
"""
|
"""
|
||||||
МодельЕдиная модель для генераций, публичных шеров и витрины галереи.
|
Единая модель для генераций, публичных шеров и витрины галереи.
|
||||||
|
|
||||||
Поля:
|
Поля:
|
||||||
• id: идентификатор записи (стандартное поле Django)
|
• id: идентификатор записи (AutoField, 4 байта, до ~2.14 млрд)
|
||||||
• s_hash_id: хэш-сумма шеринга для SVG-генерации
|
• s_hash_id: хэш-сумма шеринга для SVG-генерации (Hashids)
|
||||||
• s_title: Заголовок (до 255 символов, разрешен HTML)
|
• s_title: Заголовок (до 255 символов, разрешен HTML)
|
||||||
• file_svg: SVG-файл генерации
|
• file_svg: SVG-файл генерации
|
||||||
• i_sha1: контрольная сумма (SHA1-хэш файла)
|
• i_file_size: размер файла в байтах
|
||||||
• i_file_size: размер файла
|
• j_metadata: метаданные файла JSON (параметры алгоритма генерации)
|
||||||
• j_metadata: метаданные файла JSON (например, для хранения параметров генерации)
|
|
||||||
• i_likes_count: количество лайков
|
• i_likes_count: количество лайков
|
||||||
• i_views_count: количество просмотров
|
• i_views_count: количество просмотров
|
||||||
• i_claims_count: количество жалоб/претензий (дислайков)
|
• i_claims_count: количество жалоб/претензий (клеймов)
|
||||||
• f_score: оценка для стратегии хранения и умной очистки (Smart Retention)
|
• f_score: оценка для стратегии хранения и умной очистки (Smart Retention)
|
||||||
• i_level: уровень хранения (свежее, модерированное, защищенное от удаления)
|
• i_level: уровень хранения (свежее, модерированное, защищенное от удаления)
|
||||||
• is_public: публичный (доступный по внешней ссылке)
|
• is_public: доступность по внешней ссылке
|
||||||
• s_author_fingerprint: хеш-код браузера автора
|
• s_promo_url: промо-ссылка спонсора/автора
|
||||||
• s_promo_url: промо-ссылка (с дополнительной об авторе-спонсоре)
|
|
||||||
• s_promo_title: заголовок промо-ссылки или бейджа
|
• s_promo_title: заголовок промо-ссылки или бейджа
|
||||||
• i_promo_clicks: количество кликов по промо-ссылке
|
• i_promo_clicks: количество переходов по промо-ссылке
|
||||||
• d_created_at: дата создания записи
|
• d_created_at: дата создания записи
|
||||||
• d_updated_at: дата последнего обновления записи
|
• d_updated_at: дата последнего обновления записи
|
||||||
|
|
||||||
Методы:
|
Методы:
|
||||||
• save(): формирование s_hash_id
|
• save(): формирование s_hash_id
|
||||||
|
• rescore(): пересчёт рейтинга f_score
|
||||||
"""
|
"""
|
||||||
class Level(models.IntegerChoices):
|
class Level(models.IntegerChoices):
|
||||||
CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
|
CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
|
||||||
@@ -37,10 +37,14 @@ class TbHypn0Item(models.Model):
|
|||||||
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
|
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
|
||||||
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком 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(
|
s_hash_id = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
unique=True,
|
unique=True,
|
||||||
db_index=True,
|
|
||||||
verbose_name="ID-Хэш",
|
verbose_name="ID-Хэш",
|
||||||
)
|
)
|
||||||
s_title = models.CharField(
|
s_title = models.CharField(
|
||||||
@@ -53,18 +57,13 @@ class TbHypn0Item(models.Model):
|
|||||||
verbose_name="SVG-файл",
|
verbose_name="SVG-файл",
|
||||||
help_text="Результирующий SVG-файл генерации",
|
help_text="Результирующий SVG-файл генерации",
|
||||||
)
|
)
|
||||||
i_sha1 = models.CharField(
|
|
||||||
max_length=40,
|
|
||||||
verbose_name="SHA1",
|
|
||||||
help_text="SHA1 хэш результирующего SVG-файла",
|
|
||||||
)
|
|
||||||
i_file_size = models.PositiveIntegerField(
|
i_file_size = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
verbose_name="Размер файла",
|
verbose_name="Размер файла",
|
||||||
help_text="Размер файла в байтах",
|
help_text="Размер файла в байтах",
|
||||||
)
|
)
|
||||||
|
|
||||||
# НА ВСЯИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек
|
# НА ВСЯКИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек
|
||||||
j_metadata = models.JSONField(
|
j_metadata = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -106,14 +105,6 @@ class TbHypn0Item(models.Model):
|
|||||||
verbose_name="Публичный доступ",
|
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(
|
s_promo_url = models.URLField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -140,7 +131,7 @@ class TbHypn0Item(models.Model):
|
|||||||
auto_now=True,
|
auto_now=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Дата обновления",
|
verbose_name="Дата обновления",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Гипно-картина"
|
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)
|
TbHypn0Item.objects.get(id=self.id).update(i_promo_clicks=F('i_promo_clicks') + 1)
|
||||||
|
|
||||||
def rescore(self):
|
def rescore(self):
|
||||||
"""Пересчет рейтинга i_promo_clicks
|
r"""Пересчет рейтинга популярности картины
|
||||||
Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности
|
Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности
|
||||||
(«гравитации»), а не слепой Х-дневный таймер:
|
(«гравитации»), а не слепой таймер:
|
||||||
$$\text{Score} = \frac{\text{Likes} + \text{Bonus}_{\text{moderator}}}{(\text{Age in hours} + 2)^\gamma}$$
|
$$\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
|
age_in_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)
|
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.get(id=self.id).update(f_score=new_score)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -205,26 +196,33 @@ class TbHypn0Item(models.Model):
|
|||||||
# Сохраняем только поле s_hash_id (не перезаписываем остальное)
|
# Сохраняем только поле s_hash_id (не перезаписываем остальное)
|
||||||
super().save(update_fields=['s_hash_id'])
|
super().save(update_fields=['s_hash_id'])
|
||||||
else:
|
else:
|
||||||
# Оффер существует И уже имеет s_hash_id: сохраняем как обычно
|
# Объект существует И уже имеет s_hash_id: сохраняем как обычно
|
||||||
# Не трогаем s_hash_id, он был сформирован при создании
|
# Не трогаем s_hash_id, он был сформирован при создании
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TbVote(models.Model):
|
class TbVote(models.Model):
|
||||||
"""
|
"""
|
||||||
Анонимный учет голосов (лайков) без сохранения ПДн.
|
Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
|
||||||
|
|
||||||
Поля:
|
Поля:
|
||||||
|
• id: первичный ключ записи (BigAutoField, 8 байт, до ~9×10¹⁸)
|
||||||
• k_item: ключ объекта (foreign key), за который голосуют
|
• k_item: ключ объекта (foreign key), за который голосуют
|
||||||
• i_direction: направление голоса
|
• i_direction: направление голоса (лайк, жалоба или голос автора)
|
||||||
• s_fingerprint: Хэш-отпечаток клиент/браузер/secret для защиты он накрутки
|
• s_fingerprint: хэш-отпечаток клиент/браузер/секрет для защиты от накрутки
|
||||||
• d_created_at: дата создания
|
• d_created_at: дата и время фиксации голоса
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class Direction(models.IntegerChoices):
|
class Direction(models.IntegerChoices):
|
||||||
LIKE = VOTE_LIKE, 'Like'
|
LIKE = VOTE_LIKE, 'Like (+1)'
|
||||||
CLIME = VOTE_CLAIM, 'Clime'
|
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(
|
k_item = models.ForeignKey(
|
||||||
TbHypn0Item,
|
TbHypn0Item,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -239,9 +237,9 @@ class TbVote(models.Model):
|
|||||||
)
|
)
|
||||||
s_fingerprint = models.CharField(
|
s_fingerprint = models.CharField(
|
||||||
max_length=64,
|
max_length=64,
|
||||||
db_index=True, # ??? -- подумать нужно или нет
|
db_index=True,
|
||||||
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств",
|
|
||||||
verbose_name="Хэш устройства/отпечаток",
|
verbose_name="Хэш устройства/отпечаток",
|
||||||
|
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки и разделения устройств",
|
||||||
)
|
)
|
||||||
d_created_at = models.DateTimeField(
|
d_created_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
@@ -268,19 +266,22 @@ class TbBlogPost(models.Model):
|
|||||||
Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
|
Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
|
||||||
|
|
||||||
Поля:
|
Поля:
|
||||||
• s_title: Заголовок статьи (HTML).
|
• id: первичный ключ статьи (SmallAutoField, 2 байта, до 32 767 записей)
|
||||||
• slug: Слаг (URI-адрес) статьи.
|
• s_title: заголовок статьи (HTML)
|
||||||
• s_teaser: Тизер статьи (HTML).
|
• slug: слаг (URI-адрес) статьи
|
||||||
• s_content: Текст статьи (HTML).
|
• s_teaser: тизер статьи (HTML)
|
||||||
• f_cover_img: Картинка обложки статьи.
|
• s_content: основной текст статьи (HTML)
|
||||||
• is_published: Опубликована ли статья.
|
• f_cover_img: картинка обложки статьи
|
||||||
• d_published_at: Дата публикации статьи.
|
• is_published: флаг публикации
|
||||||
• d_created_at: Дата создания статьи.
|
• d_published_at: дата и время публикации
|
||||||
• d_updated_at: Дата обновления статьи.
|
• d_created_at: дата создания записи
|
||||||
|
• d_updated_at: дата обновления записи
|
||||||
+ а в админке виртуальные поля для управления типографом etpgrf
|
|
||||||
"""
|
"""
|
||||||
|
id = models.SmallAutoField(
|
||||||
|
primary_key=True,
|
||||||
|
verbose_name="ID",
|
||||||
|
help_text="Первичный ключ (SmallAutoField, до 32 767 записей)",
|
||||||
|
)
|
||||||
s_title = models.CharField(
|
s_title = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",
|
help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",
|
||||||
|
|||||||
Reference in New Issue
Block a user