diff --git a/apps/feed/__init__.py b/apps/feed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/feed/admin.py b/apps/feed/admin.py new file mode 100644 index 0000000..448df72 --- /dev/null +++ b/apps/feed/admin.py @@ -0,0 +1,44 @@ +from django import forms +from django.contrib import admin +from tinymce.widgets import TinyMCE +from unfold.admin import ModelAdmin, TabularInline + +from apps.feed.models import Post, PostComment + + +class PostAdminForm(forms.ModelForm): + class Meta: + model = Post + fields = '__all__' + widgets = {'body': TinyMCE()} + + +class PostCommentInline(TabularInline): + model = PostComment + extra = 0 + readonly_fields = ['author', 'created_at'] + fields = ['author', 'text', 'created_at'] + + +@admin.register(Post) +class PostAdmin(ModelAdmin): + form = PostAdminForm + list_display = ['title', 'category', 'is_important', 'is_pinned', + 'is_published', 'author', 'created_at'] + list_filter = ['category', 'is_important', 'is_pinned', 'is_published'] + search_fields = ['title', 'body'] + list_select_related = ['author'] + ordering = ['-created_at'] + inlines = [PostCommentInline] + + def save_model(self, request, obj, form, change): + if not change and not obj.author: + obj.author = request.user + super().save_model(request, obj, form, change) + + +@admin.register(PostComment) +class PostCommentAdmin(ModelAdmin): + list_display = ['post', 'author', 'text', 'created_at'] + search_fields = ['text'] + list_select_related = ['post', 'author'] diff --git a/apps/feed/apps.py b/apps/feed/apps.py new file mode 100644 index 0000000..fefdcc1 --- /dev/null +++ b/apps/feed/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class FeedConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.feed' + verbose_name = 'Лента и посты' + + def ready(self): + import apps.feed.signals # noqa diff --git a/apps/feed/migrations/0001_initial.py b/apps/feed/migrations/0001_initial.py new file mode 100644 index 0000000..12c1393 --- /dev/null +++ b/apps/feed/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.3 on 2026-07-02 21:27 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=250, verbose_name='Заголовок')), + ('body', models.TextField(verbose_name='Текст')), + ('category', models.CharField(choices=[('general', 'Загальне'), ('hr', 'HR'), ('it', 'IT'), ('network', 'Мережа')], db_index=True, default='general', max_length=20, verbose_name='Категорія')), + ('is_important', models.BooleanField(db_index=True, default=False, verbose_name='Важливо')), + ('is_pinned', models.BooleanField(db_index=True, default=False, verbose_name='Закріплено')), + ('is_published', models.BooleanField(db_index=True, default=True, verbose_name='Опубліковано')), + ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feed_posts', to=settings.AUTH_USER_MODEL, verbose_name='Автор')), + ], + options={ + 'verbose_name': 'Пост', + 'verbose_name_plural': 'Пости', + 'ordering': ['-is_pinned', '-created_at'], + }, + ), + migrations.CreateModel( + name='PostComment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('text', models.TextField(verbose_name='Коментар')), + ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feed_comments', to=settings.AUTH_USER_MODEL, verbose_name='Автор')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='feed.post', verbose_name='Пост')), + ], + options={ + 'verbose_name': 'Коментар', + 'verbose_name_plural': 'Коментарі', + 'ordering': ['created_at'], + }, + ), + migrations.AddIndex( + model_name='post', + index=models.Index(fields=['-created_at'], name='feed_post_created_e5d35a_idx'), + ), + migrations.AddIndex( + model_name='post', + index=models.Index(fields=['category', 'is_published'], name='feed_post_categor_c14611_idx'), + ), + ] diff --git a/apps/feed/migrations/0002_post_mattermost_posted_at.py b/apps/feed/migrations/0002_post_mattermost_posted_at.py new file mode 100644 index 0000000..f733ded --- /dev/null +++ b/apps/feed/migrations/0002_post_mattermost_posted_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-10 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='mattermost_posted_at', + field=models.DateTimeField(blank=True, help_text='Час останньої публікації в чат — не блокує повторну публікацію', null=True, verbose_name='Опубліковано в Mattermost'), + ), + ] diff --git a/apps/feed/migrations/0003_post_mattermost_post_ids.py b/apps/feed/migrations/0003_post_mattermost_post_ids.py new file mode 100644 index 0000000..51aec7c --- /dev/null +++ b/apps/feed/migrations/0003_post_mattermost_post_ids.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-10 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0002_post_mattermost_posted_at'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='mattermost_post_ids', + field=models.JSONField(blank=True, default=dict, help_text='{team_name: post_id} — за наявності id повторна публікація редагує існуюче повідомлення', verbose_name='ID повідомлень в Mattermost'), + ), + ] diff --git a/apps/feed/migrations/0004_postcomment_mattermost_reply_ids_and_more.py b/apps/feed/migrations/0004_postcomment_mattermost_reply_ids_and_more.py new file mode 100644 index 0000000..b64d22b --- /dev/null +++ b/apps/feed/migrations/0004_postcomment_mattermost_reply_ids_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-07-10 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0003_post_mattermost_post_ids'), + ] + + operations = [ + migrations.AddField( + model_name='postcomment', + name='mattermost_reply_ids', + field=models.JSONField(blank=True, default=dict, verbose_name='ID відповідей в Mattermost'), + ), + migrations.AddField( + model_name='postcomment', + name='mattermost_source_id', + field=models.CharField(blank=True, db_index=True, max_length=100, verbose_name='ID вихідного повідомлення з Mattermost'), + ), + ] diff --git a/apps/feed/migrations/0005_post_mattermost_targets.py b/apps/feed/migrations/0005_post_mattermost_targets.py new file mode 100644 index 0000000..08a7fc9 --- /dev/null +++ b/apps/feed/migrations/0005_post_mattermost_targets.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-10 15:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0004_postcomment_mattermost_reply_ids_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='mattermost_targets', + field=models.JSONField(blank=True, default=list, help_text='Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при створенні/редагуванні', verbose_name='Куди публікувати в Mattermost'), + ), + ] diff --git a/apps/feed/migrations/__init__.py b/apps/feed/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/feed/models.py b/apps/feed/models.py new file mode 100644 index 0000000..a43e493 --- /dev/null +++ b/apps/feed/models.py @@ -0,0 +1,120 @@ +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']+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]}' diff --git a/apps/feed/services.py b/apps/feed/services.py new file mode 100644 index 0000000..ebb0769 --- /dev/null +++ b/apps/feed/services.py @@ -0,0 +1,240 @@ +"""Публикация новостей портала в Mattermost (кнопка «Опублікувати в чат»).""" +import logging +import mimetypes +import re +from html import unescape + +from django.conf import settings +from django.core.files.storage import default_storage +from django.utils import timezone +from django.utils.html import strip_tags + +logger = logging.getLogger(__name__) + +# Команди Mattermost, які можна вибрати чекбоксами на формі створення/редагування — +# {team_name: підпис для чекбокса}. Порядок — порядок відображення на формі. +TARGET_TEAMS = { + 'akc': 'Офіс', + 'pharmacys': 'Аптеки', + 'warehouse': 'Склад', +} + +CATEGORY_EMOJI = { + 'general': '📰', + 'hr': '🧑‍💼', + 'it': '💻', + 'network': '🏥', +} + +# Mattermost за замовчуванням ріже повідомлення довші за 16383 символи — +# лишаємо запас під заголовок/посилання і не намагаємось влізти впритул. +MAX_BODY_LENGTH = 15000 +# Mattermost за замовчуванням дозволяє не більше 10 файлів на пост (FileSettings.MaxFileCount) +MAX_IMAGES = 10 + +_BLOCK_BREAK_RE = re.compile(r'|', re.IGNORECASE) +_BLANK_LINES_RE = re.compile(r'\n{3,}') +_IMG_SRC_RE = re.compile(r']+src="([^"]+)"', re.IGNORECASE) + + +def _extract_local_image_paths(html: str) -> list[str]: + """Шляхи (відносно MEDIA_ROOT) картинок з тіла поста, завантажених через + news_upload_image_view (TinyMCE). Зовнішні URL — ігноруємо, їх нема на диску.""" + media_url = settings.MEDIA_URL + paths = [] + for src in _IMG_SRC_RE.findall(html): + idx = src.find(media_url) + if idx == -1: + continue + path = src[idx + len(media_url):] + if path and path not in paths: + paths.append(path) + return paths[:MAX_IMAGES] + + +def _upload_images_for_channel(channel_id: str, image_paths: list[str]) -> list[str]: + """Залити картинки поста в конкретний канал (файли Mattermost прив'язані + до каналу завантаження) і повернути file_ids для create_post/patch_post.""" + from apps.mattermost_connector.services.bot_service import upload_channel_file + + file_ids = [] + for path in image_paths: + if not default_storage.exists(path): + continue + try: + with default_storage.open(path, 'rb') as f: + content = f.read() + except Exception as e: + logger.warning(f'Не удалось прочитать файл {path} для публикации в Mattermost: {e}') + continue + + filename = path.rsplit('/', 1)[-1] + content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + file_id = upload_channel_file(channel_id, filename, content, content_type) + if file_id: + file_ids.append(file_id) + return file_ids + + +def _html_to_text(html: str) -> str: + """Повний текст новини для чату: зберігаємо розбиття на абзаци/рядки + (інакше strip_tags склеює весь HTML в суцільний текст без пробілів).""" + text = _BLOCK_BREAK_RE.sub('\n', html) + text = unescape(strip_tags(text)) + text = _BLANK_LINES_RE.sub('\n\n', text) + return text.strip() + + +def _build_message(post) -> str: + url = f'{settings.PORTAL_BASE_URL.rstrip("/")}/news/{post.pk}/' + body = _html_to_text(post.body) + if len(body) > MAX_BODY_LENGTH: + body = body[:MAX_BODY_LENGTH].rstrip() + f'…\n\n[Читати повністю]({url})' + emoji = CATEGORY_EMOJI.get(post.category, '📰') + important = ' 🔥 Важливо!' if post.is_important else '' + return f'{emoji} **{post.title}**{important}\n\n{body}\n\n[Відкрити на порталі]({url})' + + +def publish_post_to_mattermost(post) -> dict: + """Постить новину в town-square команд, обраних чекбоксами на формі + (post.mattermost_targets — список кодів команд з TARGET_TEAMS), разом із + картинками з тіла поста (заливаються в Mattermost як файли — працює навіть + якщо портал не публічний, на відміну від посилань на media). + Якщо повідомлення в цій команді вже публікувалось раніше (є id в + mattermost_post_ids) — редагує його замість того, щоб плодити дублі; + якщо редагування не вдалось (напр. повідомлення видалили в Mattermost) — + публікує нове і оновлює id. Команди, які прибрали з targets при редагуванні, + НЕ видаляються автоматично — старе повідомлення просто перестає оновлюватись. + Повертає {team_name: True/False} — успіх публікації в кожній команді.""" + from apps.mattermost_connector.models import MattermostChannel + from apps.mattermost_connector.services.bot_service import create_channel_post, update_channel_post + + team_names = post.mattermost_targets or [] + if not team_names: + return {} + message = _build_message(post) + image_paths = _extract_local_image_paths(post.body) + post_ids = dict(post.mattermost_post_ids or {}) + + results = {} + for team_name in team_names: + channel = MattermostChannel.objects.filter( + team__name=team_name, name='town-square', + ).first() + if not channel: + results[team_name] = False + continue + + file_ids = _upload_images_for_channel(channel.channel_id, image_paths) if image_paths else [] + + existing_id = post_ids.get(team_name) + if existing_id and update_channel_post(existing_id, message, file_ids=file_ids or None): + results[team_name] = True + continue + + new_id = create_channel_post(channel.channel_id, message, file_ids=file_ids or None) + if new_id: + post_ids[team_name] = new_id + results[team_name] = bool(new_id) + + if any(results.values()): + post.mattermost_post_ids = post_ids + post.mattermost_posted_at = timezone.now() + post.save(update_fields=['mattermost_post_ids', 'mattermost_posted_at']) + + return results + + +def delete_post_from_mattermost(post) -> None: + """Видалити опубліковані повідомлення в усіх каналах — викликається перед + Post.delete() з порталу. Mattermost сам видаляє репліки (коментарі) разом + з кореневим повідомленням, окремо чистити mattermost_reply_ids не треба.""" + if not post.mattermost_post_ids: + return + + from apps.mattermost_connector.services.bot_service import delete_channel_post + + for post_id in post.mattermost_post_ids.values(): + delete_channel_post(post_id) + + +def push_comment_to_mattermost_thread(comment) -> None: + """Відповідь на коментар з порталу — в тред під новиною в кожній команді, + куди новина була опублікована. Викликається сигналом при створенні + PostComment (apps/feed/signals.py) — ТІЛЬКИ для коментарів з порталу, + не для тих, що самі підтягнуті з Mattermost (mattermost_source_id).""" + from apps.mattermost_connector.models import MattermostChannel + from apps.mattermost_connector.services.bot_service import create_channel_post + + post = comment.post + if not post.mattermost_post_ids: + return + + author_name = comment.author.get_full_name() if comment.author else 'Хтось на порталі' + message = f'💬 **{author_name}** (з порталу):\n{comment.text}' + + reply_ids = {} + for team_name, root_id in post.mattermost_post_ids.items(): + channel = MattermostChannel.objects.filter(team__name=team_name, name='town-square').first() + if not channel: + continue + reply_id = create_channel_post(channel.channel_id, message, root_id=root_id) + if reply_id: + reply_ids[team_name] = reply_id + + if reply_ids: + comment.mattermost_reply_ids = reply_ids + comment.save(update_fields=['mattermost_reply_ids']) + + +def sync_comments_from_mattermost(post) -> int: + """Підтягнути нові відповіді з тредів Mattermost як PostComment. + Викликається при кожному відкритті news_detail — без Celery/webhook. + Повертає кількість імпортованих коментарів.""" + if not post.mattermost_post_ids: + return 0 + + from apps.employees.models.base import Employee + from apps.feed.models import PostComment + from apps.mattermost_connector.services.bot_service import get_thread_replies + from core.models import ConnectorConfig + + cfg = ConnectorConfig.objects.filter(connector_type='mattermost').first() + bot_user_id = (cfg.extra_config or {}).get('bot_user_id') if cfg else None + + known_ids = set() + for c in post.comments.all(): + known_ids.update((c.mattermost_reply_ids or {}).values()) + if c.mattermost_source_id: + known_ids.add(c.mattermost_source_id) + + imported = 0 + for root_id in post.mattermost_post_ids.values(): + for reply in get_thread_replies(root_id): + reply_id = reply['id'] + if reply_id in known_ids: + continue + if bot_user_id and reply.get('user_id') == bot_user_id: + continue # власні службові повідомлення бота — не коментар + + employee = Employee.objects.filter(mattermost_user_id=reply.get('user_id')).first() + if not employee: + continue # не змогли зіставити автора — пропускаємо, а не пишемо "невідомо" + + text = (reply.get('message') or '').strip() + if not text: + continue + + new_comment = PostComment.objects.create( + post=post, author=employee, text=text, mattermost_source_id=reply_id, + ) + create_at_ms = reply.get('create_at') + if create_at_ms: + from datetime import datetime, timezone as dt_timezone + actual_dt = datetime.fromtimestamp(create_at_ms / 1000, tz=dt_timezone.utc) + PostComment.objects.filter(pk=new_comment.pk).update(created_at=actual_dt) + + known_ids.add(reply_id) + imported += 1 + + return imported diff --git a/apps/feed/signals.py b/apps/feed/signals.py new file mode 100644 index 0000000..e46dc45 --- /dev/null +++ b/apps/feed/signals.py @@ -0,0 +1,16 @@ +"""Публікація коментарів з порталу у тред Mattermost під новиною.""" +from django.db.models.signals import post_save +from django.dispatch import receiver + +from apps.feed.models import PostComment + + +@receiver(post_save, sender=PostComment) +def push_comment_to_mattermost(sender, instance, created, **kwargs): + if not created or instance.mattermost_source_id: + # mattermost_source_id — коментар прийшов ІЗ Mattermost (sync_comments_from_mattermost), + # відправляти його назад у чат не можна — зациклиться + return + + from apps.feed.services import push_comment_to_mattermost_thread + push_comment_to_mattermost_thread(instance) diff --git a/apps/feed/tests.py b/apps/feed/tests.py new file mode 100644 index 0000000..c75cc4a --- /dev/null +++ b/apps/feed/tests.py @@ -0,0 +1,451 @@ +""" +Тесты ленты: создание постов (права), фильтры, закрепление, +детальная страница, комментарии, посты на дашборде. +""" +import io +import shutil +import tempfile +from datetime import date + +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings + +from apps.feed.models import Post, PostComment + +Employee = get_user_model() + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'feed.user{_employee_seq}', + 'email': f'feed.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +class FeedPageTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.staff = make_employee(is_staff=True) + cls.user = make_employee() + cls.author = make_employee(first_name='Марія', last_name='Петренко') + + def test_feed_lists_published_posts(self): + Post.objects.create(title='Опублікований пост', body='текст', author=self.author) + Post.objects.create(title='Чернетка', body='текст', author=self.author, + is_published=False) + self.client.force_login(self.user) + response = self.client.get('/news/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Опублікований пост') + self.assertNotContains(response, 'Чернетка') + + def test_pinned_posts_are_separated(self): + pinned = Post.objects.create(title='Закріплений', body='т', author=self.author, + is_pinned=True) + regular = Post.objects.create(title='Звичайний', body='т', author=self.author) + self.client.force_login(self.user) + response = self.client.get('/news/', HTTP_HX_REQUEST='true') + self.assertIn(pinned, response.context['pinned_posts']) + self.assertIn(regular, response.context['posts']) + self.assertContains(response, 'Закріплено') + + def test_category_filter(self): + Post.objects.create(title='IT новина', body='т', category='it', author=self.author) + Post.objects.create(title='HR новина', body='т', category='hr', author=self.author) + self.client.force_login(self.user) + response = self.client.get('/news/?category=it', HTTP_HX_REQUEST='true') + self.assertContains(response, 'IT новина') + self.assertNotContains(response, 'HR новина') + + def test_important_filter(self): + Post.objects.create(title='Термінова', body='т', is_important=True, + author=self.author) + Post.objects.create(title='Планова', body='т', author=self.author) + self.client.force_login(self.user) + response = self.client.get('/news/?category=important', HTTP_HX_REQUEST='true') + self.assertContains(response, 'Термінова') + self.assertNotContains(response, 'Планова') + + def test_invalid_filter_falls_back_to_all(self): + Post.objects.create(title='Будь-яка', body='т', author=self.author) + self.client.force_login(self.user) + response = self.client.get('/news/?category=hacked', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['active_filter'], '') + self.assertContains(response, 'Будь-яка') + + def test_staff_can_create_post(self): + self.client.force_login(self.staff) + response = self.client.post('/news/', { + 'title': 'Нова публікація', + 'body': 'Текст публікації', + 'category': 'it', + 'is_important': 'on', + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + post = Post.objects.get(title='Нова публікація') + self.assertEqual(post.author, self.staff) + self.assertEqual(post.category, 'it') + self.assertTrue(post.is_important) + self.assertFalse(post.is_pinned) + + def test_empty_body_shows_error_and_creates_nothing(self): + self.client.force_login(self.staff) + response = self.client.post('/news/', { + 'title': 'Тільки заголовок', + 'body': '

 

', # пустая разметка из TinyMCE + 'category': 'general', + }, HTTP_HX_REQUEST='true') + self.assertFalse(Post.objects.filter(title='Тільки заголовок').exists()) + self.assertContains(response, 'Публікацію не збережено') + + def test_image_only_body_is_accepted(self): + self.client.force_login(self.staff) + self.client.post('/news/', { + 'title': 'Фотозвіт', + 'body': '

', + 'category': 'general', + }, HTTP_HX_REQUEST='true') + post = Post.objects.get(title='Фотозвіт') + self.assertIn('/media/feed/x.png', post.body) + + def test_regular_user_cannot_create_post(self): + self.client.force_login(self.user) + self.client.post('/news/', { + 'title': 'Спроба', 'body': 'т', 'category': 'general', + }, HTTP_HX_REQUEST='true') + self.assertFalse(Post.objects.filter(title='Спроба').exists()) + + def test_create_form_visible_only_to_staff(self): + self.client.force_login(self.user) + response = self.client.get('/news/', HTTP_HX_REQUEST='true') + self.assertNotContains(response, 'Нова публікація') + + self.client.force_login(self.staff) + response = self.client.get('/news/', HTTP_HX_REQUEST='true') + self.assertContains(response, 'Нова публікація') + + def test_invalid_category_falls_back_to_general(self): + self.client.force_login(self.staff) + self.client.post('/news/', { + 'title': 'Пост', 'body': 'т', 'category': 'hacked', + }, HTTP_HX_REQUEST='true') + self.assertEqual(Post.objects.get(title='Пост').category, 'general') + + def test_anonymous_redirected(self): + response = self.client.get('/news/') + self.assertEqual(response.status_code, 302) + + +class BodySanitizationTests(TestCase): + """Тело поста — HTML из TinyMCE; XSS вычищается при сохранении""" + + @classmethod + def setUpTestData(cls): + cls.author = make_employee() + + def test_script_tags_are_stripped(self): + post = Post.objects.create( + title='XSS', author=self.author, + body='

Текст

', + ) + self.assertNotIn(' +
+ + + + Опублікувати в чат: + {% for code, label in mattermost_target_teams %} + + {% endfor %} + +
+ + + diff --git a/templates/pages/news.html b/templates/pages/news.html index 79c9e91..98c3be0 100644 --- a/templates/pages/news.html +++ b/templates/pages/news.html @@ -7,113 +7,167 @@ - + {% if form_error %} +
+ {{ form_error }} +
+ {% endif %} + + {% if request.user.is_staff %} + +
+ + + + + Нова публікація + +
+ {% csrf_token %} + + + +
+ + + + Опублікувати в чат: + {% for code, label in mattermost_target_teams %} + + {% endfor %} + +
+
+
+ {% endif %} + +
- - - - + {% with base_style="display:inline-flex; align-items:center; gap:0.375rem; padding:0.375rem 0.875rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; text-decoration:none; cursor:pointer;" %} + Всі + Важливі + {% for value, label in categories %} + {{ label }} + {% endfor %} + {% endwith %}
- -
-
- 📌 Закріплено - Важливо -
-

Квартальний звіт — необхідно заповнити до 15 квітня

-

Всім керівникам відділів необхідно заповнити та подати квартальні звіти до 15 квітня 2026 року. Форми звітів доступні у розділі «Документи». Зверніться до відділу HR за додатковою інформацією.

-
-
-
МП
- Марія Петренко · 08.04.2026 -
- Читати далі → -
-
- - -
- -
- Останні оголошення -
- - -
-
- -
-
-
-
Оновлення системи безпеки
- 06.04.2026 -
-

IT відділ повідомляє про заплановане оновлення системи безпеки 10 квітня з 18:00 до 20:00. Портал буде недоступний.

- IT -
-
- - -
-
- -
-
-
-
Зустріч директорів — 12 квітня
- 05.04.2026 -
-

Нагадування про щомісячну зустріч директорів у конференц-залі (3 поверх) о 10:00. Порядок денний у вкладенні.

- HR -
-
- - -
-
- -
-
-
-
Нові інструкції з охорони праці
- 03.04.2026 -
-

Опубліковані нові інструкції з охорони праці. Всім співробітникам необхідно ознайомитися до 20 квітня.

- HR -
-
- - -
-
- -
-
-
-
Оновлення корпоративної політики
- 28.03.2026 -
-

Внесено зміни до корпоративної політики щодо дистанційної роботи. Нова версія документу доступна у розділі «Документи».

- Загальне -
-
- - -
-
- -
-
-
-
Відкриття нової аптеки А-47 у Харкові
- 20.03.2026 -
-

Рада повідомити про відкриття нової аптеки АКС А-47 у Харкові. Це 47-а точка нашої мережі.

- Мережа -
-
- + + + + + {% for post in pinned_posts %} + {% include 'partials/feed-post-card.html' with post=post pinned=True %} + {% endfor %} + + {% for post in posts %} + {% include 'partials/feed-post-card.html' with post=post pinned=False %} + {% empty %} + {% if not pinned_posts %} +
+ {% if active_filter %}За цим фільтром публікацій немає{% else %}Публікацій поки немає{% endif %}
+ {% endif %} + {% endfor %}
diff --git a/templates/partials/feed-post-card.html b/templates/partials/feed-post-card.html new file mode 100644 index 0000000..d7c9e31 --- /dev/null +++ b/templates/partials/feed-post-card.html @@ -0,0 +1,57 @@ +{% load common_tags %} +{# Карточка поста в ленте: полный вывод. Контекст: post, pinned #} +
+ + +
+
+ {% if post.is_pinned %} + 📌 Закріплено + {% endif %} + {% if post.is_important %}Важливо{% endif %} + {{ post.get_category_display }} +
+ {{ post.created_at|date:"d.m.Y H:i" }} +
+ + + +

{{ post.title }}

+
+ + +
+
{{ post.body|safe }}
+
+
+ + + +
+
+
{{ post.author|initials }}
+ {{ post.author.get_full_name|default:"—" }} +
+
+ + + {{ post.num_comments|default:0 }} + + {% if post.can_edit %} + Редагувати + {% endif %} + {% if post.can_delete %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+