mod: модели и админка (5) драфт
This commit is contained in:
+13
@@ -17,6 +17,19 @@ DJANGO_EMAIL_HOST_USER=you@email.com
|
|||||||
DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME
|
DJANGO_EMAIL_HOST_PASSWORD=CHANGE_ME
|
||||||
DJANGO_EMAIL_FROM=you@email.com
|
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 (можно сменить для безопасности, чтобы боты не могли её найти)
|
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
|
||||||
ADMIN_URL=admin/
|
ADMIN_URL=admin/
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -199,11 +199,22 @@ STORAGES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Специфичные для проекта настройки
|
# СПЕЦИФИЧНЫЕ ДЛЯ ПРОЕКТА НАСТРОЙКИ
|
||||||
LVL_CANDIDATE = 10
|
# ====== Уровни для SVG-генераций (используется для чисти файлов при достижении лимитов) ======
|
||||||
LVL_PRE_MODERATED = 20
|
LVL_CANDIDATE = 0
|
||||||
|
LVL_PRE_MODERATED = 10
|
||||||
LVL_MODERATED = 30
|
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:
|
if DEBUG:
|
||||||
|
|||||||
+129
-25
@@ -1,40 +1,70 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from hashids import Hashids
|
||||||
from hypn0.settings import *
|
from hypn0.settings import *
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
class TbHypn0Item(models.Model):
|
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):
|
class Level(models.IntegerChoices):
|
||||||
CANDIDATE = LVL_CANDIDATE, 'Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
|
CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация. Запрет на удаление до Х дней.
|
||||||
LEVEL_1 = LVL_PRE_MODERATED, 'Плеск бессознательного' # Есть "лайки", не проверено, можно удалять при низком score
|
LEVEL_1 = LVL_PRE_MODERATED, 'Voted: Плеск бессознательного' # Есть "лайки", не проверено, можно удалять при низком score
|
||||||
LEVEL_2 = LVL_MODERATED, 'Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
|
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Есть "лайки", проверено, можно удалять при низком score
|
||||||
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Глубокий транс' # Нельзя удалять, даже при низком score
|
IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Нельзя удалять, даже при низком score
|
||||||
|
|
||||||
s_hash_id = models.CharField(
|
s_hash_id = models.CharField(
|
||||||
max_length=16,
|
max_length=16,
|
||||||
unique=True,
|
unique=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Хэш-идентификатор",
|
verbose_name="ID-Хэш",
|
||||||
)
|
)
|
||||||
s_title = models.CharField(
|
s_title = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name="Название картины",
|
verbose_name="Название генерации",
|
||||||
|
help_text="Заголовок для генерации. Включена HTML-типографика. 255 символов максимум.",
|
||||||
)
|
)
|
||||||
file_svg = models.FileField(
|
file_svg = models.FileField(
|
||||||
upload_to="svg/%Y/%m/",
|
upload_to="svg/%Y/%m/",
|
||||||
verbose_name="SVG-файл",
|
verbose_name="SVG-файл",
|
||||||
|
help_text="Результирующий SVG-файл генерации",
|
||||||
)
|
)
|
||||||
i_sha1 = models.CharField(
|
i_sha1 = models.CharField(
|
||||||
max_length=40,
|
max_length=40,
|
||||||
verbose_name="SHA1",
|
verbose_name="SHA1",
|
||||||
|
help_text="SHA1 хэш результирующего SVG-файла",
|
||||||
)
|
)
|
||||||
i_file_size = models.PositiveIntegerField(
|
i_file_size = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
help_text="Размер файла в байтах",
|
|
||||||
verbose_name="Размер файла",
|
verbose_name="Размер файла",
|
||||||
|
help_text="Размер файла в байтах",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Снимок параметров для возможного клонирования настроек
|
# НА ВСЯИЙ СЛУЧАЙ: Метаданные и снимок параметров для возможного клонирования настроек
|
||||||
j_params = models.JSONField(
|
j_metadata = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name="Параметры генерации",
|
verbose_name="Параметры генерации",
|
||||||
@@ -45,29 +75,29 @@ class TbHypn0Item(models.Model):
|
|||||||
i_likes_count = models.PositiveIntegerField(
|
i_likes_count = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Количество лайков",
|
verbose_name="Число лайков",
|
||||||
)
|
)
|
||||||
i_views_count = models.PositiveIntegerField(
|
i_views_count = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
verbose_name="Количество просмотров",
|
verbose_name="Число просмотров",
|
||||||
)
|
)
|
||||||
i_reports_count = models.PositiveIntegerField(
|
i_claims_count = models.PositiveIntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
verbose_name="Количество жалоб",
|
verbose_name="Число жалоб",
|
||||||
)
|
)
|
||||||
f_score = models.FloatField(
|
f_score = models.FloatField(
|
||||||
default=0.0,
|
default=0.0,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Рейтинг для Smart Retention",
|
|
||||||
verbose_name="Рейтинг популярности",
|
verbose_name="Рейтинг популярности",
|
||||||
|
help_text="Рейтинг для Smart Retention и определения, что удалять при чистке по лимитам, а что оставлять",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Модерация
|
# Модерация
|
||||||
i_level = models.IntegerField(
|
i_level = models.IntegerField(
|
||||||
choices=Level.choices,
|
choices=Level.choices,
|
||||||
default=Level.CANDIDATE,
|
default=Level.CANDIDATE,
|
||||||
help_text="Уровень доступа",
|
help_text="Уровень",
|
||||||
verbose_name="Уровень доступа",
|
verbose_name="В какой группе находится SVG-файл генерации",
|
||||||
)
|
)
|
||||||
is_public = models.BooleanField(
|
is_public = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -111,9 +141,73 @@ class TbHypn0Item(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.s_title} ({self.s_hash_id})"
|
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):
|
class TbVote(models.Model):
|
||||||
"""Анонимный учет голосов (лайков) без сохранения ПДн."""
|
"""
|
||||||
|
Анонимный учет голосов (лайков) без сохранения ПДн.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
• k_item: ключ объекта (foreign key), за который голосуют
|
||||||
|
• s_fingerprint: Хэш-отпечаток клиент/браузер/secret для защиты он накрутки
|
||||||
|
• d_created_at: дата создания
|
||||||
|
"""
|
||||||
|
|
||||||
k_item = models.ForeignKey(
|
k_item = models.ForeignKey(
|
||||||
TbHypn0Item,
|
TbHypn0Item,
|
||||||
@@ -151,12 +245,23 @@ class TbBlogPost(models.Model):
|
|||||||
"""
|
"""
|
||||||
Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
|
Статьи блога, документация и инфо-страницы (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(
|
s_title = models.CharField(
|
||||||
max_length=200,
|
max_length=255,
|
||||||
help_text="HTML-заголовок, очищенный и типографированный etpgrf",
|
help_text="HTML-заголовок (с HTML-тегами, типографированный etpgrf)",
|
||||||
verbose_name="Заголовок",
|
verbose_name="Заголовок",
|
||||||
)
|
)
|
||||||
slug = models.SlugField(
|
slug = models.SlugField(
|
||||||
@@ -164,7 +269,7 @@ class TbBlogPost(models.Model):
|
|||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="URL-слаг",
|
verbose_name="URL-слаг",
|
||||||
)
|
)
|
||||||
s_summary = models.TextField(
|
s_teaser = models.TextField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="HTML-контент тизера, очищенный и типографированный etpgrf",
|
help_text="HTML-контент тизера, очищенный и типографированный etpgrf",
|
||||||
@@ -186,8 +291,7 @@ class TbBlogPost(models.Model):
|
|||||||
verbose_name="Опубликовано",
|
verbose_name="Опубликовано",
|
||||||
)
|
)
|
||||||
d_published_at = models.DateTimeField(
|
d_published_at = models.DateTimeField(
|
||||||
null=True,
|
default=timezone.now,
|
||||||
blank=True,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Дата публикации",
|
verbose_name="Дата публикации",
|
||||||
)
|
)
|
||||||
@@ -195,7 +299,7 @@ class TbBlogPost(models.Model):
|
|||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
verbose_name="Дата создания",
|
verbose_name="Дата создания",
|
||||||
)
|
)
|
||||||
d_created_at = models.DateTimeField(
|
d_updated_at = models.DateTimeField(
|
||||||
auto_now=True,
|
auto_now=True,
|
||||||
verbose_name="Дата обновления",
|
verbose_name="Дата обновления",
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+16
-1
@@ -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)"]
|
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)"]
|
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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.3"
|
version = "26.3"
|
||||||
@@ -256,4 +271,4 @@ brotli = ["brotli"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.14"
|
python-versions = "^3.14"
|
||||||
content-hash = "b9c94317c0624ff0156fd9db6187c2a709b4c0ad5cce5424a745a5091e3816ad"
|
content-hash = "eecf8236a6668e8b92042c0d9c1e366d6ac175e209258664483f38fa2ec785e1"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ python = "^3.14"
|
|||||||
pillow = "^12.3.0"
|
pillow = "^12.3.0"
|
||||||
django = "^6.0.8"
|
django = "^6.0.8"
|
||||||
django-environ = "^0.14.0"
|
django-environ = "^0.14.0"
|
||||||
|
hashids = "^1.3.1"
|
||||||
gunicorn = "^26.0.0"
|
gunicorn = "^26.0.0"
|
||||||
whitenoise = "^6.12.0"
|
whitenoise = "^6.12.0"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user