35f2091e0b
- 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>
121 lines
5.5 KiB
Python
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]}'
|