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'(p|div|li|h[1-6]|tr)>|
', 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': '

Текст
', + ) + self.assertNotIn(' +Всім керівникам відділів необхідно заповнити та подати квартальні звіти до 15 квітня 2026 року. Форми звітів доступні у розділі «Документи». Зверніться до відділу HR за додатковою інформацією.
-