mod: про модели и хранение данных...

This commit is contained in:
2026-08-22 14:58:32 +03:00
parent 381ec3eaf0
commit 2bec2e860e
+111 -82
View File
@@ -51,21 +51,21 @@
│ │ ├── 404.html │ │ ├── 404.html
│ │ └── 500.html │ │ └── 500.html
│ │ │ │
│ └── halftone/ # Django app │ └── hypn0_site/ # Основное Django app
│ ├── __init__.py │ ├── __init__.py
│ ├── urls.py │ ├── urls.py
│ ├── views.py # главная + генерация │ ├── views.py # главная + генерация
│ ├── forms.py # формы параметров │ ├── forms.py # формы параметров
│ ├── models.py # FeaturedSVG + votes │ ├── models.py # TbHypn0Item, TbVote, TbBlogPost
│ ├── services/ │ ├── services/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── halftone.py # ядро из main.py │ │ └── halftone.py # ядро генерации
│ ├── templates/ │ ├── templates/
│ │ └── halftone/ │ │ └── halftone/
│ │ └── index.html │ │ └── index.html
│ └── management/ │ └── management/
│ └── commands/ │ └── commands/
│ ├── cleanup_temp.py # очистка temp (14 дней) │ ├── cleanup_storage.py # очистка по рейтингу Smart Retention
│ └── cleanup_reports.py # удаление по жалобам │ └── cleanup_reports.py # удаление по жалобам
├── .gitea/workflows/ # CI/CD ├── .gitea/workflows/ # CI/CD
@@ -115,12 +115,12 @@ def generate_halftone_svg(
```python ```python
# Главная — GET → рендерим index.html # Главная — GET → рендерим index.html
# Генерация — POST (HTMX) → возвращаем SVG фрагмент # Генерация — POST (HTMX) → возвращаем SVG фрагмент
# Скачивание — GET /download/{temp_id} → отдаём файл + удаляем исходник # Скачивание — GET /download/{hash_id} → отдаём файл
# Просмотр публичного — GET /view/{hash} → отдаём SVG из temp/ # Просмотр публичного — GET /view/{hash_id} → отдаём страницу просмотра
# Галерея — GET /gallery → список FeaturedSVG # Галерея — GET /gallery → список TbHypn0Item
# Просмотр в галерее — GET /gallery/{hash} # Просмотр в галерее — GET /gallery/{hash_id}
# Report — POST /report/{hash} → increment reports_count # Vote/Report — POST /gallery/{hash_id}/vote → учет голоса в TbVote (Like/Claim)
# Promote (admin) — админ переносит SVG из temp → gallery # Promote (admin) — модератор повышает i_level (Level.LEVEL_2 или Level.IMMORTAL)
``` ```
### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`) ### 3. Фронтенд (`hypn0/halftone/templates/halftone/index.html`)
@@ -335,8 +335,17 @@ HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вс
- **Куки-согласие и идентификация устройства (Cookie-токен):** - **Куки-согласие и идентификация устройства (Cookie-токен):**
- При согласии с правилами («Подчиниться Гипножабе!» в `allow-tracking.html`) клиенту выставляется долгоживущая анонимная кука устройства `hypn0_vid` со случайным UUID (`Max-Age=2 года`, `SameSite=Lax`, `HttpOnly`). - При согласии с правилами («Подчиниться Гипножабе!» в `allow-tracking.html`) клиенту выставляется долгоживущая анонимная кука устройства `hypn0_vid` со случайным UUID (`Max-Age=2 года`, `SameSite=Lax`, `HttpOnly`).
- **Голосование доступно только при наличии согласия на куки:** Если посетитель не принял соглашение (кука отсутствует), голосование заблокировано интерфейсом и отклоняется бэкендом (с требованием подчиниться Гипножабе). - **Голосование доступно только при наличии согласия на куки:** Если посетитель не принял соглашение (кука отсутствует), голосование заблокировано интерфейсом и отклоняется бэкендом (с требованием подчиниться Гипножабе).
- **Лайки и учет голосов:** - **Учет голосов, защита от накрутки и определение авторства (`TbVote`):**
- **Бэкенд:** Голоса учитываются анонимно через связку `(item_id, fingerprint)`. Поле `fingerprint` формируется как необратимый хэш: `sha256(visitor_uuid + SECRET_KEY)`. Это надежно разделяет разные компьютеры в одной корпоративной сети/NAT и не содержит открытых ПДн (Zero-PII). - **Бэкенд:** Голоса учитываются анонимно через модель `TbVote` со связкой `(k_item, s_fingerprint)`. Поле `s_fingerprint` формируется как необратимый хэш: `sha256(visitor_uuid + SECRET_KEY)`. Это надежно разделяет разные компьютеры в одной корпоративной сети/NAT и не содержит открытых ПДн (Zero-PII).
- **Направление голоса (`i_direction` / `Direction`):**
- `Direction.LIKE` (`+1`) — обычный лайк посетителя.
- `Direction.CLAIM` (`-2`) — жалоба / клейм на неподобающий контент.
- `Direction.AUTHOR` (`+2`) — голос создателя картины.
- **Идентификация автора через `Direction.AUTHOR`:**
- При генерации картины в `TbVote` автоматически создается запись с `i_direction=Direction.AUTHOR`.
- Это исключает необходимость хранить отдельное поле `s_author_fingerprint` в `TbHypn0Item` (нет дублирования данных).
- Благодаря `UniqueConstraint(fields=["k_item", "s_fingerprint"])` автор автоматически защищен от «самолайка» (повторный лайк со своего браузера не пройдет).
- Свежая генерация сразу получает стартовый вес (`+2`), предотвращающий мгновенное удаление при низком `f_score`.
- **Фронтенд:** Для быстродействия UI и исключения лишней нагрузки список лайкнутых картин дублируется локально на клиенте в **`localStorage`** (`hypn0_votes`). На базе Alpine.js выполняется мгновенная индикация проголосованных работ. - **Фронтенд:** Для быстродействия UI и исключения лишней нагрузки список лайкнутых картин дублируется локально на клиенте в **`localStorage`** (`hypn0_votes`). На базе Alpine.js выполняется мгновенная индикация проголосованных работ.
- **Соответствие GDPR / 152-ФЗ:** Персональные данные не собираются и не хранятся. - **Соответствие GDPR / 152-ФЗ:** Персональные данные не собираются и не хранятся.
@@ -347,91 +356,111 @@ HTMX: форма с `hx-post`, `hx-encoding="multipart/form-data"` → SVG вс
#### 4.3. Стратегия хранения и умной очистки (Smart Retention) #### 4.3. Стратегия хранения и умной очистки (Smart Retention)
Проект не выступает постоянным файлообменником или бесплатным CDN. Пользователь скачивает сгенерированный SVG для личного использования. Проект не выступает постоянным файлообменником или бесплатным CDN. Пользователь скачивает сгенерированный SVG для личного использования.
Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности («гравитации»), а не слепой 14-дневный таймер: Для оптимизации диска на сервере используется динамическая очистка на основе рейтинга популярности («гравитации»), а не слепой таймер:
$$\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}$$
- **Квота хранилища:** Задается жесткий лимит объема (например, 500 МБ или N тысяч файлов). - **Квота хранилища:** Задается жесткий лимит объема (например, 500 МБ или N тысяч файлов).
- **Очистка (`manage.py cleanup_storage`):** При превышении лимита удаляются файлы с наименьшим `Score`. - **Уровни хранения (`i_level` / `TbHypn0Item.Level`):**
- **Иммунитет к удалению:** Работы, отмеченные модератором (`is_curated=True` / `Bonus`), и закрепленные промо-картины сохраняются бессрочно. - `CANDIDATE` (`LVL_CANDIDATE`): свежая генерация, защита от удаления на начальный период.
- `LEVEL_1` (`LVL_MIN_VOTES`): получен минимум лайков («Лёгкий транс»).
- `LEVEL_2` (`LVL_MODERATED`): проверено и одобрено («Одобрено Мозговым Слизнем»), удаление возможно при низком `f_score`.
- `IMMORTAL` (`LVL_LOCK_FOR_DELETION`): полный иммунитет к удалению даже при низком рейтинге («Глубокий транс»).
- **Очистка (`manage.py cleanup_storage`):** При превышении квоты диска удаляются файлы с наименьшим `f_score` (не затрагивая защищенные уровни).
#### 4.4. SEO, шеринг и коммерциализация #### 4.4. SEO, шеринг и коммерциализация
- **Вшивание ссылки в SVG (Zero-cost Branding):** В скачиваемый SVG на этапе генерации добавляется мета-комментарий со ссылкой на генератор (`<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->` и `<metadata>`). - **Вшивание ссылки в SVG (Zero-cost Branding):** В скачиваемый SVG на этапе генерации добавляется мета-комментарий со ссылкой на генератор (`<!-- Generated by Hypn0 Generator (https://hypn0.ru) -->` и `<metadata>`).
- **Страница просмотра (`/v/<hash>`):** Для внешнего шеринга и индексации поисковиками генерируется легкая HTML-страница с OpenGraph разметкой и кнопкой «Открыть настройки в генераторе». - **Страница просмотра (`/v/<hash>`):** Для внешнего шеринга и индексации поисковиками генерируется легкая HTML-страница с OpenGraph разметкой и кнопкой «Открыть настройки в генераторе».
- **Возможность промо/коммерциализации:** Поддержка опциональных полей (промо-ссылка, бейдж автора/спонсора, счетчик кликов). - **Возможность промо/коммерциализации:** Поддержка опциональных полей (`s_promo_url`, `s_promo_title`, `i_promo_clicks`).
#### 4.5. Блог, статические страницы и типографика (HTML + `etpgrf`) #### 4.5. Блог, статические страницы и типографика (HTML + `etpgrf`)
- **Отказ от конвертации Markdown «на лету»:** Вместо оверхеда с парсингом Markdown статьи и инфо-страницы хранятся напрямую в формате **HTML**. - **Отказ от конвертации Markdown «на лету»:** Вместо оверхеда с парсингом Markdown статьи и инфо-страницы хранятся напрямую в формате **HTML**.
- **Очистка HTML в админке:** При вводе/сохранении в Django Admin контент проходит валидацию и санитизацию (очистку от вредоносного или мусорного HTML/инлайн-стилей). - **Очистка HTML в админке:** При вводе/сохранении в Django Admin контент проходит валидацию и санитизацию (очистку от вредоносного или мусорного HTML/инлайн-стилей).
- **Типографирование через `etpgrf`:** Для безупречной верстки текста (неразрывные пробелы, висячая пунктуация, кавычки-елочки, тире) используется библиотека типографирования **`etpgrf`**. - **Типографирование через `etpgrf`:** Для безупречной верстки текста (неразрывные пробелы, висячая пунктуация, кавычки-елочки, тире) используется библиотека типографирования **`etpgrf`**.
#### 4.6. Архитектура моделей #### 4.6. Архитектура моделей (Венгерская нотация и типизация)
В моделях используется венгерская нотация префиксов полей (`s_` — string/text/url, `i_` — integer, `f_` — float, `j_` — json, `k_` — foreign key, `d_` — datetime, `is_` — boolean, `file_` — file), а также оптимизированные типы первичных ключей (`SmallAutoField`, `AutoField`, `BigAutoField`).
```python ```python
class HalftoneItem(models.Model): class TbHypn0Item(models.Model):
""" """
Единая модель для генераций, публичных шеров и витрины галереи. Единая модель для генераций, публичных шеров и витрины галереи.
""" """
hash_id = models.CharField(max_length=16, unique=True, db_index=True) class Level(models.IntegerChoices):
title = models.CharField(max_length=150, verbose_name="Название картины") CANDIDATE = LVL_CANDIDATE, 'Candidate: Шум сознания' # Свежая генерация
svg_file = models.FileField(upload_to="svg/%Y/%m/") LEVEL_1 = LVL_MIN_VOTES, 'Level 1: Лёгкий транс' # Есть первичные голоса
LEVEL_2 = LVL_MODERATED, 'Moderated: Одобрено Мозговым Слизнем' # Модерировано
# Снимок параметров для возможного клонирования настроек IMMORTAL = LVL_LOCK_FOR_DELETION, 'Locked: Глубокий транс' # Иммунитет к удалению
params_json = models.JSONField(default=dict, blank=True)
file_size = models.PositiveIntegerField(default=0, help_text="Размер файла в байтах")
# Популярность и модерация id = models.AutoField(primary_key=True, verbose_name="ID") # 4 байта, до ~2.14 млрд
likes_count = models.PositiveIntegerField(default=0, db_index=True) s_hash_id = models.CharField(max_length=16, unique=True, verbose_name="ID-Хэш")
views_count = models.PositiveIntegerField(default=0) s_title = models.CharField(max_length=255, verbose_name="Заголовок")
reports_count = models.PositiveIntegerField(default=0) file_svg = models.FileField(upload_to="svg/%Y/%m/", verbose_name="SVG-файл")
score = models.FloatField(default=0.0, db_index=True, help_text="Рейтинг для Smart Retention") i_file_size = models.PositiveIntegerField(default=0, verbose_name="Размер файла (байт)")
is_curated = models.BooleanField(default=False, help_text="Выбор модератора / галерея (иммунитет к удалению)") # Метаданные и снимок параметров алгоритма генерации
is_public = models.BooleanField(default=True, help_text="Доступность по прямой ссылке") j_metadata = models.JSONField(default=dict, blank=True, verbose_name="Метаданные")
# Популярность, модерация и рейтинг Smart Retention
i_likes_count = models.PositiveIntegerField(default=0, db_index=True, verbose_name="Лайки")
i_views_count = models.PositiveIntegerField(default=0, verbose_name="Просмотры")
i_claims_count = models.PositiveIntegerField(default=0, verbose_name="Жалобы")
f_score = models.FloatField(default=0.0, db_index=True, verbose_name="Рейтинг Smart Retention")
i_level = models.IntegerField(choices=Level.choices, default=Level.CANDIDATE, verbose_name="Уровень хранения")
is_public = models.BooleanField(default=True, verbose_name="Публичный доступ")
# Промо-блок (на перспективу) # Промо-блок (на перспективу)
promo_url = models.URLField(blank=True, default="", verbose_name="Промо-ссылка") s_promo_url = models.URLField(blank=True, default="", verbose_name="Промо-ссылка")
promo_title = models.CharField(max_length=100, blank=True, default="", verbose_name="Бейдж/автор") s_promo_title = models.CharField(max_length=100, blank=True, default="", verbose_name="Бейдж/автор")
promo_clicks = models.PositiveIntegerField(default=0) i_promo_clicks = models.PositiveIntegerField(default=0, verbose_name="Клики по промо")
created_at = models.DateTimeField(auto_now_add=True, db_index=True) 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="Дата обновления")
class HalftoneVote(models.Model): class TbVote(models.Model):
""" """
Анонимный учет голосов (лайков) без сохранения ПДн. Анонимный учет голосов (лайков, жалоб, авторства) без сохранения ПДн.
""" """
item = models.ForeignKey(HalftoneItem, on_delete=models.CASCADE, related_name="votes") class Direction(models.IntegerChoices):
fingerprint = models.CharField( 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") # 8 байт, до ~9×10¹⁸
k_item = models.ForeignKey(TbHypn0Item, on_delete=models.CASCADE, related_name="votes", verbose_name="Картина")
i_direction = models.IntegerField(choices=Direction.choices, default=Direction.LIKE, verbose_name="Направление")
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="Хэш устройства",
help_text="SHA256(visitor_uuid + SECRET_KEY) для защиты от накрутки"
) )
created_at = models.DateTimeField(auto_now_add=True) d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата голосования")
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=["item", "fingerprint"], name="unique_item_fingerprint_vote") models.UniqueConstraint(fields=["k_item", "s_fingerprint"], name="unique_item_fingerprint_vote")
] ]
class BlogPost(models.Model): class TbBlogPost(models.Model):
""" """
Статьи блога, документация и инфо-страницы (Privacy Policy и др.). Статьи блога, документация и инфо-страницы (Privacy Policy и др.).
+ виртуальные поля для управления типографом etpgrf
""" """
title = models.CharField(max_length=200, help_text="HTML-заголовок, очищенный и типографированный etpgrf") id = models.SmallAutoField(primary_key=True, verbose_name="ID") # 2 байта, до 32 767 записей
slug = models.SlugField(max_length=200, unique=True) s_title = models.CharField(max_length=255, help_text="HTML-заголовок, очищенный и типографированный etpgrf")
summary = models.TextField(max_length=500, blank=True, help_text="HTML-контент тизера, очищенный и типографированный etpgrf") slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-слаг")
content = models.TextField(help_text="HTML-контент статьи, очищенный и типографированный etpgrf") s_teaser = models.TextField(max_length=1024, blank=True, help_text="HTML-тизер, очищенный и типографированный etpgrf")
cover_image = models.ImageField(upload_to="blog/covers/%Y/", blank=True, null=True) s_content = models.TextField(help_text="HTML-контент статьи, очищенный и типографированный etpgrf")
f_cover_img = models.ImageField(upload_to="blog/covers/%Y/", blank=True, null=True, verbose_name="Обложка")
is_published = models.BooleanField(default=False) is_published = models.BooleanField(default=False, verbose_name="Опубликовано")
published_at = models.DateTimeField(null=True, blank=True, db_index=True) d_published_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name="Дата публикации")
created_at = models.DateTimeField(auto_now_add=True) d_created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True) d_updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
``` ```
## Шаги реализации ## Шаги реализации
@@ -521,32 +550,32 @@ CLI больше не нужен.
## Файлы для создания (итого) ## Файлы для создания (итого)
| Файл | Назначение | | Файл | Назначение |
|---------------------------------------------------------|------------------------| |-----------------------------------------------------------|------------------------|
| `hypn0/manage.py` | Django CLI | | `hypn0/manage.py` | Django CLI |
| `hypn0/hypn0/__init__.py` | Settings pkg | | `hypn0/hypn0/__init__.py` | Settings pkg |
| `hypn0/hypn0/settings.py` | Конфиг | | `hypn0/hypn0/settings.py` | Конфиг |
| `hypn0/hypn0/urls.py` | URL routing | | `hypn0/hypn0/urls.py` | URL routing |
| `hypn0/hypn0/wsgi.py` | WSGI entry | | `hypn0/hypn0/wsgi.py` | WSGI entry |
| `hypn0/hypn0/templates/base.html` | Базовый шаблон | | `hypn0/hypn0/templates/base.html` | Базовый шаблон |
| `hypn0/halftone/__init__.py` | App pkg | | `hypn0/hypn0_site/__init__.py` | App pkg |
| `hypn0/halftone/urls.py` | URL app | | `hypn0/hypn0_site/urls.py` | URL app |
| `hypn0/halftone/views.py` | Views | | `hypn0/hypn0_site/views.py` | Views |
| `hypn0/halftone/forms.py` | Forms | | `hypn0/hypn0_site/forms.py` | Forms |
| `hypn0/halftone/models.py` | FeaturedSVG | | `hypn0/hypn0_site/models.py` | TbHypn0Item, TbVote... |
| `hypn0/halftone/services/halftone.py` | Ядро | | `hypn0/hypn0_site/services/halftone.py` | Ядро |
| `hypn0/halftone/templates/halftone/index.html` | Главная | | `hypn0/hypn0_site/templates/halftone/index.html` | Главная |
| `hypn0/halftone/management/commands/cleanup_temp.py` | Очистка temp (14 дней) | | `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention |
| `hypn0/halftone/management/commands/cleanup_reports.py` | Очистка по жалобам | | `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам |
| `public/static/css/site.css` | Стили | | `public/static/css/site.css` | Стили |
| `public/static/js/site.js` | JS | | `public/static/js/site.js` | JS |
| `.env.sample` | Env template | | `.env.sample` | Env template |
| `Dockerfile` | Сборка | | `Dockerfile` | Сборка |
| `docker-compose.local.yml` | Dev | | `docker-compose.local.yml` | Dev |
| `docker-compose.prod.yml` | Prod | | `docker-compose.prod.yml` | Prod |
| `config/nginx/hypn0-app--external-nginx.conf` | Nginx | | `config/nginx/hypn0-app--external-nginx.conf` | Nginx |
| `.gitea/workflows/docker-publish.yaml` | CI/CD | | `.gitea/workflows/docker-publish.yaml` | CI/CD |
| `database/.gitignore` | Игнор БД | | `database/.gitignore` | Игнор БД |
## Файлы для модификации ## Файлы для модификации