Files
ACORE/apps/feed/models.py
T
lokses 35f2091e0b Модуль ленты (feed): посты, комментарии, TinyMCE, санитизация
- Post (категории, важное/закреплено/опубликовано, автор) + PostComment;
  тело — HTML из TinyMCE, санитизация nh3 в save() (XSS), загрузка картинок
  отдельным endpoint с проверкой содержимого (data:-URI вырезаются)
- Лента с полным выводом постов, фильтры, сворачивание длинных;
  детальная страница с комментариями; edit — автор/staff, delete — автор
- Публикация постов в каналы Mattermost (mattermost_targets), сигналы
- Тесты (40): публикация/черновики, права, санитизация, картинки,
  выравнивание, комментарии, лента/дашборд

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:31:20 +03:00

121 lines
5.5 KiB
Python

import re
import nh3
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.models.base import BaseModel
_IMG_SRC_RE = re.compile(r'<img[^>]+src="([^"]+)"', re.IGNORECASE)
# Разрешённый HTML для тела поста (nh3-дефолт + инлайн-стили из TinyMCE:
# выравнивание, цвет, отступы — на всех тегах, которые стилизует редактор)
ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
for _tag in ('span', 'p', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'li', 'ul', 'ol', 'div', 'img', 'table', 'blockquote', 'pre', 'figure'):
ALLOWED_ATTRIBUTES.setdefault(_tag, set()).add('style')
class Post(BaseModel):
CATEGORY_CHOICES = [
('general', _('Загальне')),
('hr', 'HR'),
('it', 'IT'),
('network', _('Мережа')),
]
# CSS-класс бейджа для категории (шаблонные бейджи из дизайн-системы)
CATEGORY_BADGES = {
'general': 'badge-muted',
'hr': 'badge-warning',
'it': 'badge-info',
'network': 'badge-success',
}
title = models.CharField(_('Заголовок'), max_length=250)
body = models.TextField(_('Текст'))
category = models.CharField(
_('Категорія'), max_length=20,
choices=CATEGORY_CHOICES, default='general', db_index=True,
)
is_important = models.BooleanField(_('Важливо'), default=False, db_index=True)
is_pinned = models.BooleanField(_('Закріплено'), default=False, db_index=True)
is_published = models.BooleanField(_('Опубліковано'), default=True, db_index=True)
mattermost_posted_at = models.DateTimeField(
_('Опубліковано в Mattermost'), null=True, blank=True,
help_text=_('Час останньої публікації в чат — не блокує повторну публікацію'),
)
mattermost_post_ids = models.JSONField(
_('ID повідомлень в Mattermost'), default=dict, blank=True,
help_text=_('{team_name: post_id} — за наявності id повторна публікація редагує існуюче повідомлення'),
)
mattermost_targets = models.JSONField(
_('Куди публікувати в Mattermost'), default=list, blank=True,
help_text=_('Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при створенні/редагуванні'),
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='feed_posts', verbose_name=_('Автор'),
)
class Meta:
verbose_name = _('Пост')
verbose_name_plural = _('Пости')
ordering = ['-is_pinned', '-created_at']
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['category', 'is_published']),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
# Тело поста — HTML из редактора; чистим от XSS всегда,
# независимо от того, откуда пришло (портал, админка, API)
self.body = nh3.clean(self.body, attributes=ALLOWED_ATTRIBUTES)
super().save(*args, **kwargs)
@property
def badge_class(self):
return self.CATEGORY_BADGES.get(self.category, 'badge-muted')
@property
def first_image_url(self):
"""URL першої картинки в тілі поста — для мініатюри в прев'ю (дашборд тощо)."""
match = _IMG_SRC_RE.search(self.body)
return match.group(1) if match else ''
class PostComment(BaseModel):
post = models.ForeignKey(
Post, on_delete=models.CASCADE,
related_name='comments', verbose_name=_('Пост'),
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='feed_comments', verbose_name=_('Автор'),
)
text = models.TextField(_('Коментар'))
# Синхронізація коментарів з треду Mattermost під публікацією новини:
# reply_ids — коли коментар СТВОРЕНО на порталі і відправлено в чат (може бути
# decilька — новина могла піти в кілька команд); source_id — коли коментар,
# навпаки, ПІДТЯГНУТО з відповіді в Mattermost (дедуп при повторному підтягуванні
# і запобіжник, щоб такий коментар не відправився назад у чат — цикл).
mattermost_reply_ids = models.JSONField(_('ID відповідей в Mattermost'), default=dict, blank=True)
mattermost_source_id = models.CharField(
_('ID вихідного повідомлення з Mattermost'), max_length=100, blank=True, db_index=True,
)
class Meta:
verbose_name = _('Коментар')
verbose_name_plural = _('Коментарі')
ordering = ['created_at']
def __str__(self):
return f'{self.author}: {self.text[:50]}'