diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index 266aa03c..bf036c6b 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -109,6 +109,7 @@ class DepartmentViewSet(viewsets.ModelViewSet): return [AdminOrActionOrModelPerms()] if self.action in { "members", + "discussion_chat", "user_perms", "list", "retrieve", @@ -449,6 +450,39 @@ class DepartmentViewSet(viewsets.ModelViewSet): links = _build_links_for_dept(dept, EmployeeBriefSerializer) return Response({"count": len(links), "results": links}, status=200) + @action(detail=True, methods=["get"], url_path="discussion-chat") + def discussion_chat(self, request, pk=None): + """Возвращает comments-chat отдела для входа из detail page.""" + from communications.comments_helpers import get_or_create_comments_chat + + dept = self.get_object() + chat = get_or_create_comments_chat( + dept, + created_by=None, + name=dept.name, + ) + next_flags = dict(chat.flags or {}) + if next_flags.get("show_in_messages") is not True: + next_flags["show_in_messages"] = True + + if ( + chat.created_by_id is not None + or chat.name != dept.name + or chat.flags != next_flags + ): + chat.created_by = None + chat.name = dept.name + chat.flags = next_flags + chat.save(update_fields=["created_by", "name", "flags"]) + return Response( + { + "chat_id": chat.id, + "chat_type": chat.type, + "name": chat.name, + }, + status=status.HTTP_200_OK, + ) + @action(detail=True, methods=["get"], url_path="user-perms") def user_perms(self, request, pk=None): """GET /api/v1/departments/{id}/user-perms/. @@ -466,6 +500,12 @@ class DepartmentViewSet(viewsets.ModelViewSet): "can_assign_roles": has_dept_perm( request.user, dept.id, DeptPerm.ASSIGN_ROLE ), + "can_publish_posts": has_dept_perm( + request.user, dept.id, DeptPerm.CREATE_POST + ), + "can_manage_feed": has_dept_perm( + request.user, dept.id, DeptPerm.MANAGE_FEED + ), } return Response(data, status=200) diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index ead29630..20a2c185 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -49,6 +49,9 @@ class PostListSerializer(serializers.ModelSerializer): source="created_at", format="%d.%m.%Y %H:%M", read_only=True ) + pinned = serializers.SerializerMethodField() + pinned_global = serializers.BooleanField(read_only=True, default=False) + pinned_department = serializers.BooleanField(read_only=True, default=False) # ожидания фронта is_liked = serializers.BooleanField(read_only=True, default=False) comments_count = serializers.IntegerField( @@ -75,6 +78,8 @@ class PostListSerializer(serializers.ModelSerializer): "created_at", "created_at_display", "pinned", + "pinned_global", + "pinned_department", "likes_count", "comments_count", "author_id", @@ -85,6 +90,8 @@ class PostListSerializer(serializers.ModelSerializer): "created_at", "created_at_display", "pinned", + "pinned_global", + "pinned_department", "likes_count", "comments_count", "author_id", @@ -105,10 +112,20 @@ class PostListSerializer(serializers.ModelSerializer): request = self.context.get("request") return build_media_url(obj.attachment, request) + @extend_schema_field(serializers.BooleanField()) + def get_pinned(self, obj): + scope = self.context.get("pin_scope") or "global" + if scope == "department": + return bool(getattr(obj, "pinned_department", False)) + return bool(getattr(obj, "pinned_global", False)) + class PostSerializer(PostListSerializer): """Сериализатор для деталей поста""" + body = serializers.CharField(required=False, allow_blank=True) + image = serializers.ImageField(required=False, allow_null=True, write_only=True) + attachment = serializers.FileField(required=False, allow_null=True, write_only=True) user_has_liked = serializers.SerializerMethodField() class Meta(PostListSerializer.Meta): @@ -122,6 +139,13 @@ class PostSerializer(PostListSerializer): return PostLike.objects.filter(post=obj, user=request.user).exists() return False + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + data["image"] = build_media_url(instance.image, request) + data["attachment"] = build_media_url(instance.attachment, request) + return data + def validate(self, attrs): # значения с учётом partial t = attrs.get("type", getattr(self.instance, "type", None)) diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index fe588946..0d3dd9a6 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -66,13 +66,18 @@ class PostViewSet(viewsets.ModelViewSet): """ queryset = Post.objects.select_related("author", "department").order_by( - "-pinned", "-created_at" + "-pinned_global", "-created_at" ) parser_classes = [MultiPartParser, FormParser, JSONParser] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["title", "body"] - ordering_fields = ["created_at", "pinned", "likes_count"] - ordering = ["-pinned", "-created_at"] + ordering_fields = [ + "created_at", + "pinned_global", + "pinned_department", + "likes_count", + ] + ordering = ["-pinned_global", "-created_at"] class PostDeptPerm(AdminOrDeptAllowed): """Департаментные права для операций над постами отдела. @@ -153,6 +158,30 @@ class PostViewSet(viewsets.ModelViewSet): def get_serializer_class(self): return PostListSerializer if self.action == "list" else PostSerializer + def get_serializer_context(self): + context = super().get_serializer_context() + context["pin_scope"] = self._resolve_pin_scope() + return context + + def _resolve_pin_scope(self, post: Post | None = None) -> str: + request_data = self.request.data if self.request.method not in {"GET", "HEAD"} else {} + scope = ( + self.request.query_params.get("pin_scope") + or self.request.query_params.get("scope") + or request_data.get("scope") + or "" + ).strip() + if scope in {"global", "department"}: + return scope + + post_type = (self.request.query_params.get("type") or "").strip() + department_id = _to_id(self.request.query_params.get("department")) + if post_type == TYPE_DEPARTMENT and department_id is not None: + return "department" + if post is not None and getattr(post, "type", None) == TYPE_DEPARTMENT: + return "department" + return "global" + def get_queryset(self): qs = super().get_queryset() user = self.request.user @@ -197,11 +226,20 @@ class PostViewSet(viewsets.ModelViewSet): author_id = _to_id(qp.get("author")) if author_id is not None: qs = qs.filter(author_id=author_id) + pin_scope = self._resolve_pin_scope() + pinned_field = ( + "pinned_department" if pin_scope == "department" else "pinned_global" + ) pinned = qp.get("pinned") if pinned in ("true", "1"): - qs = qs.filter(pinned=True) + qs = qs.filter(**{pinned_field: True}) elif pinned in ("false", "0"): - qs = qs.filter(pinned=False) + qs = qs.filter(**{pinned_field: False}) + + if pin_scope == "department" and dept_id is not None: + qs = qs.order_by("-pinned_department", "-created_at") + else: + qs = qs.order_by("-pinned_global", "-created_at") return qs @@ -231,17 +269,67 @@ class PostViewSet(viewsets.ModelViewSet): def pin(self, request, pk=None): """Пинует пост (только staff/superuser).""" post = self.get_object() - if not post.pinned: - Post.objects.filter(pk=post.pk).update(pinned=True) - return Response({"pinned": True}) + scope = self._resolve_pin_scope(post=post) + if scope == "department": + if post.type != TYPE_DEPARTMENT or post.department_id is None: + return Response( + {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not post.pinned_department: + Post.objects.filter(pk=post.pk).update(pinned_department=True) + return Response( + { + "scope": "department", + "pinned": True, + "pinned_global": bool(post.pinned_global), + "pinned_department": True, + } + ) + + if not post.pinned_global: + Post.objects.filter(pk=post.pk).update(pinned_global=True) + return Response( + { + "scope": "global", + "pinned": True, + "pinned_global": True, + "pinned_department": bool(post.pinned_department), + } + ) @action(detail=True, methods=["post"], permission_classes=[IsAdminUser]) def unpin(self, request, pk=None): """Снимает пин (только staff/superuser).""" post = self.get_object() - if post.pinned: - Post.objects.filter(pk=post.pk).update(pinned=False) - return Response({"pinned": False}) + scope = self._resolve_pin_scope(post=post) + if scope == "department": + if post.type != TYPE_DEPARTMENT or post.department_id is None: + return Response( + {"detail": "Закрепление в ленте отдела доступно только для публикаций отдела."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if post.pinned_department: + Post.objects.filter(pk=post.pk).update(pinned_department=False) + return Response( + { + "scope": "department", + "pinned": False, + "pinned_global": bool(post.pinned_global), + "pinned_department": False, + } + ) + + if post.pinned_global: + Post.objects.filter(pk=post.pk).update(pinned_global=False) + return Response( + { + "scope": "global", + "pinned": False, + "pinned_global": False, + "pinned_department": bool(post.pinned_department), + } + ) @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated]) def like(self, request, pk=None): diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 24a21c35..e5db6be3 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -174,8 +174,9 @@ class ChatViewSet(viewsets.ModelViewSet): ОПТИМИЗИРОВАНО: Используем денормализованное поле unread_count из ChatReadState вместо подзапросов COUNT(*). Это убирает N+1 проблему и ускоряет в ~100x. - ФИЛЬТРАЦИЯ: Чаты типа 'comments' исключены из списка (list action), - но доступны по прямой ссылке (retrieve action). + ФИЛЬТРАЦИЯ: Чаты типа 'comments' по умолчанию исключены из списка, + кроме явно помеченных object-chats (`flags.show_in_messages=true`). + По прямой ссылке retrieve доступны все comments-чаты. """ user = self.request.user @@ -184,6 +185,7 @@ class ChatViewSet(viewsets.ModelViewSet): | Q(participants=user) | Q(include_all_users=True) | Q(created_by=user) # Создатель всегда видит свои чаты + | Q(type="comments") ).select_related( 'created_by', 'context_content_type' ).prefetch_related( @@ -215,10 +217,12 @@ class ChatViewSet(viewsets.ModelViewSet): ), ).distinct() - # Исключаем чаты-комментарии из общего списка - # Они доступны только через прямой запрос (retrieve) или через контекст поста + # В список попадают только явно выставленные comments-чаты. + # Остальные object-comment threads доступны по прямому retrieve. if self.action == 'list': - queryset = queryset.exclude(type='comments') + queryset = queryset.exclude( + Q(type='comments') & ~Q(flags__show_in_messages=True) + ) return queryset.order_by('-created_at') diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 7c9314bd..8fbcb48d 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -18,6 +18,7 @@ from django.utils import timezone from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction from .serialization import serialize_message +from .utils import user_can_access_chat logger = logging.getLogger(__name__) @@ -597,6 +598,7 @@ class ChatConsumerMixin: Q(type="global") | Q(id__in=membership_chat_ids) | Q(include_all_users=True) + | Q(type="comments", flags__show_in_messages=True) ) .values_list("id", flat=True) .distinct() @@ -625,6 +627,9 @@ class ChatConsumerMixin: @database_sync_to_async def _user_can_access(self, chat, user): """Проверка доступа пользователя к чату""" + if chat.type == "comments": + return user_can_access_chat(chat, user) + if chat.type == "global": return True diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 5bcc67e0..39ab2fb6 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -232,7 +232,7 @@ def _notify_comment(message): # Нет привязанного объекта - обычная обработка return - recipients_set = set() + recipient_ids = set() context_obj = chat.context_object # Определяем автора объекта в зависимости от типа @@ -251,6 +251,23 @@ def _notify_comment(message): object_author = getattr(context_obj, 'uploaded_by', None) object_url = f"/documents?document={context_obj.id}" object_type = "документа" + elif model_name == 'Department': + from employees.models import EmployeeDepartment, RoleAssignment + + object_url = ActionURLs.chat_detail(chat.id) + object_type = "отдела" + + member_ids = EmployeeDepartment.objects.filter( + department_id=context_obj.id, + is_active=True, + ).values_list("employee_id", flat=True) + recipient_ids.update(member_ids) + + role_only_ids = RoleAssignment.objects.filter( + role__department_id=context_obj.id, + is_active=True, + ).values_list("employee_id", flat=True) + recipient_ids.update(role_only_ids) elif hasattr(context_obj, 'employee'): # Request object_author = getattr(context_obj, 'employee', None) object_url = f"/requests?request={context_obj.id}" @@ -258,7 +275,7 @@ def _notify_comment(message): # 1. Уведомляем автора объекта if object_author and object_author.id != author.id: - recipients_set.add(object_author) + recipient_ids.add(object_author.id) # 2. Уведомляем всех, кто участвовал в обсуждении from ..models import Message @@ -270,16 +287,18 @@ def _notify_comment(message): ).values_list('author_id', flat=True).distinct() if previous_commenters: - from django.contrib.auth import get_user_model - User = get_user_model() - recipients_set.update( - User.objects.filter(id__in=previous_commenters, is_active=True) - ) + recipient_ids.update(previous_commenters) + + recipient_ids.discard(author.id) + if not recipient_ids: + return + + recipients = User.objects.filter(id__in=recipient_ids, is_active=True) # Отправляем уведомления author_name = author.get_full_name() or author.username - for recipient in recipients_set: + for recipient in recipients: _send_notification( sender=author, recipient=recipient, diff --git a/backend/communications/rules.py b/backend/communications/rules.py index 226f4aed..55b86b15 100644 --- a/backend/communications/rules.py +++ b/backend/communications/rules.py @@ -35,6 +35,15 @@ def is_chat_member(user, chat): if chat.type == "global": return True + if chat.type == "comments" and chat.context_object_id: + try: + return chat.get_participants().filter(pk=user.pk).exists() + except Exception: + logger.exception( + "[is_chat_member] failed to resolve comments chat participants" + ) + return False + return ( ChatMembership.objects.filter( chat=chat, user=user, is_active=True @@ -173,6 +182,15 @@ def has_send_messages_permission(user, chat): if chat.type in ["private", "global"]: return True + if chat.type == "comments" and chat.context_object_id: + try: + return chat.get_participants().filter(pk=user.pk).exists() + except Exception: + logger.exception( + "[has_send_messages_permission] failed to resolve comments chat participants" + ) + return False + # Для чатов с ChatMembership проверяем флаг can_send_messages if hasattr(chat, "memberships"): from .models import ChatMembership diff --git a/backend/employees/signals/common.py b/backend/employees/signals/common.py index 1ae77a27..5009b583 100644 --- a/backend/employees/signals/common.py +++ b/backend/employees/signals/common.py @@ -3,20 +3,18 @@ Обработчики: - Создание кадровых событий (EmployeeAction) -- Создание главных чатов отделов - Автоматизация из заявок (requests_app) """ import logging from datetime import datetime -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone from employees.constants import ACTION_HIRED -from employees.models import Employee, EmployeeAction, Department +from employees.models import Employee, EmployeeAction logger = logging.getLogger(__name__) @@ -52,43 +50,6 @@ def create_hired_action(sender, instance: Employee, created, **kwargs): ) -@receiver(post_save, sender=Department) -def create_main_department_chat(sender, instance, created, **kwargs): - """ - Создает главный чат для нового отдела (EUSRR-specific logic). - - Использует GenericFK (context_object) и flags['is_primary']. - """ - if not created: - return - - try: - from communications.models import Chat - - dept_ct = ContentType.objects.get_for_model(Department) - - # Проверка: существует ли уже чат для этого отдела - existing = Chat.objects.filter( - type="channel", - context_content_type=dept_ct, - context_object_id=instance.id, - flags__is_primary=True - ).exists() - - if not existing: - Chat.objects.create( - type="channel", - context_content_type=dept_ct, - context_object_id=instance.id, - flags={'is_primary': True}, - is_main=True, - name=f"Основной чат {instance.name}" - ) - except ImportError: - # communications app не установлен - пропускаем - pass - - # ============================================================ # Обработчики заявок (requests_app) # ============================================================ diff --git a/backend/employees/utils.py b/backend/employees/utils.py index 8e209847..34225fd0 100644 --- a/backend/employees/utils.py +++ b/backend/employees/utils.py @@ -341,6 +341,12 @@ def resolve_chat_participants_for_department(context_object, **kwargs): return Employee.objects.none() department = context_object + chat = kwargs.get("chat") + + # Основной discussion/comments chat отдела доступен всем активным + # сотрудникам сайта, а не только физическому составу отдела. + if getattr(chat, "type", None) == "comments": + return Employee.objects.filter(is_active=True) # Получаем активных сотрудников отдела employee_ids = EmployeeDepartment.objects.filter( diff --git a/backend/feed/admin.py b/backend/feed/admin.py index 9b9ba4e4..0e825a7f 100644 --- a/backend/feed/admin.py +++ b/backend/feed/admin.py @@ -10,7 +10,8 @@ class PostAdmin(admin.ModelAdmin): "author", "department", "created_at", - "pinned", + "pinned_global", + "pinned_department", ] - list_filter = ["type", "department", "pinned"] + list_filter = ["type", "department", "pinned_global", "pinned_department"] search_fields = ["title", "body", "author__last_name"] diff --git a/backend/feed/migrations/0008_alter_post_body.py b/backend/feed/migrations/0008_alter_post_body.py new file mode 100644 index 00000000..ee5187ee --- /dev/null +++ b/backend/feed/migrations/0008_alter_post_body.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-04-14 07:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feed', '0007_delete_comment'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='body', + field=models.TextField(blank=True, verbose_name='Текст'), + ), + ] diff --git a/backend/feed/migrations/0009_split_post_pins_by_scope.py b/backend/feed/migrations/0009_split_post_pins_by_scope.py new file mode 100644 index 00000000..f6457f0a --- /dev/null +++ b/backend/feed/migrations/0009_split_post_pins_by_scope.py @@ -0,0 +1,62 @@ +from django.db import migrations, models + + +def copy_legacy_pin_to_global(apps, schema_editor): + Post = apps.get_model("feed", "Post") + Post.objects.filter(pinned=True).update(pinned_global=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("feed", "0008_alter_post_body"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="pinned_global", + field=models.BooleanField( + default=False, + verbose_name="Закреплено в общей ленте", + ), + ), + migrations.AddField( + model_name="post", + name="pinned_department", + field=models.BooleanField( + default=False, + verbose_name="Закреплено в ленте отдела", + ), + ), + migrations.RunPython( + copy_legacy_pin_to_global, + migrations.RunPython.noop, + ), + migrations.AlterModelOptions( + name="post", + options={ + "ordering": ["-pinned_global", "-created_at"], + "verbose_name": "Публикация", + "verbose_name_plural": "Публикации", + }, + ), + migrations.RemoveIndex( + model_name="post", + name="feed_post_pinned_e86c9a_idx", + ), + migrations.AddIndex( + model_name="post", + index=models.Index( + fields=["pinned_global", "created_at"], + name="feed_post_pin_global_idx", + ), + ), + migrations.AddIndex( + model_name="post", + index=models.Index( + fields=["pinned_department", "created_at"], + name="feed_post_pin_department_idx", + ), + ), + ] diff --git a/backend/feed/models.py b/backend/feed/models.py index 3233a5d2..08d457bb 100644 --- a/backend/feed/models.py +++ b/backend/feed/models.py @@ -82,9 +82,21 @@ class Post(models.Model): ), ) title = models.CharField("Заголовок", max_length=200) - body = models.TextField("Текст") + body = models.TextField("Текст", blank=True) created_at = models.DateTimeField("Создано", auto_now_add=True) - pinned = models.BooleanField("Закреплено", default=False) + pinned = models.BooleanField( + "Закреплено (legacy)", + default=False, + editable=False, + ) + pinned_global = models.BooleanField( + "Закреплено в общей ленте", + default=False, + ) + pinned_department = models.BooleanField( + "Закреплено в ленте отдела", + default=False, + ) type = models.CharField( "Тип публикации", max_length=20, choices=TYPE_CHOICES ) @@ -105,10 +117,17 @@ class Post(models.Model): class Meta: verbose_name = "Публикация" verbose_name_plural = "Публикации" - ordering = ["-pinned", "-created_at"] + ordering = ["-pinned_global", "-created_at"] indexes = [ models.Index(fields=["type", "created_at"]), - models.Index(fields=["pinned", "created_at"]), + models.Index( + fields=["pinned_global", "created_at"], + name="feed_post_pin_global_idx", + ), + models.Index( + fields=["pinned_department", "created_at"], + name="feed_post_pin_department_idx", + ), models.Index(fields=["created_at"]), models.Index(fields=["department", "-created_at"]), models.Index(fields=["author", "-created_at"]), diff --git a/backend/feed/notifications/handlers.py b/backend/feed/notifications/handlers.py index c76f6a65..f1cbf2c3 100644 --- a/backend/feed/notifications/handlers.py +++ b/backend/feed/notifications/handlers.py @@ -20,6 +20,32 @@ from .config import ( Employee = get_user_model() +def _department_post_recipients(department): + """Получатели новостей отдела: физический состав + role-only сотрудники.""" + if department is None: + return [] + + recipients: dict[int, Employee] = {} + + for employee in department.active_employees: + if getattr(employee, "is_active", False): + recipients[employee.id] = employee + + role_only_qs = ( + Employee.objects.filter( + is_active=True, + role_assignments__role__department=department, + role_assignments__is_active=True, + ) + .distinct() + .order_by("id") + ) + for employee in role_only_qs: + recipients[employee.id] = employee + + return list(recipients.values()) + + def notify_new_post(post): """ Отправляет уведомления о новой публикации. @@ -114,8 +140,8 @@ def get_post_recipients(post): return list(Employee.objects.filter(is_active=True)) elif post.type == TYPE_DEPARTMENT and post.department: - # Новость отдела - сотрудникам отдела - return list(post.department.employees.filter(is_active=True)) + # Новость отдела — физическому составу + role-only участникам + return _department_post_recipients(post.department) elif post.type == TYPE_EMPLOYEE: # Личная публикация - подписчикам diff --git a/backend/tests/api/v1/employees/test_department_discussion.py b/backend/tests/api/v1/employees/test_department_discussion.py new file mode 100644 index 00000000..7ffa37aa --- /dev/null +++ b/backend/tests/api/v1/employees/test_department_discussion.py @@ -0,0 +1,263 @@ +import itertools + +import pytest +from communications.comments_helpers import create_comment +from communications.models import Chat, Message +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from employees.models import ( + Department, + DepartmentRole, + EmployeeDepartment, + RoleAssignment, +) +from rest_framework import status +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + +User = get_user_model() + +_phone_seq = itertools.count(3000) + + +def _unique_phone() -> str: + return f"+7999555{next(_phone_seq):04d}" + + +@pytest.fixture +def api_client(): + return APIClient() + + +def make_user( + email: str, + *, + first_name: str = "FN", + last_name: str = "LN", + active: bool = True, +): + user = User.objects.create( + email=email, + phone_number=_unique_phone(), + first_name=first_name, + last_name=last_name, + is_active=active, + email_verified=True, + ) + user.set_password("pass") + user.save() + return user + + +def discussion_chat_url(department_id: int) -> str: + base = reverse("api:v1:departments-detail", args=[department_id]) + return f"{base}discussion-chat/" + + +def test_department_creation_does_not_create_auto_channel_chat(): + department = Department.objects.create(name="Finance") + dept_ct = ContentType.objects.get_for_model(Department) + + assert not Chat.objects.filter( + type="channel", + context_content_type=dept_ct, + context_object_id=department.id, + ).exists() + + +def test_department_discussion_chat_endpoint_returns_comments_chat( + api_client: APIClient, +): + user = make_user("user@example.com") + department = Department.objects.create(name="Support") + api_client.force_authenticate(user=user) + + response = api_client.get(discussion_chat_url(department.id)) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["chat_type"] == "comments" + assert payload["name"] == department.name + + chat = Chat.objects.get(id=payload["chat_id"]) + assert chat.type == "comments" + assert chat.context_object == department + assert chat.created_by is None + assert chat.flags.get("show_in_messages") is True + + +def test_department_discussion_chat_endpoint_clears_legacy_owner( + api_client: APIClient, +): + user = make_user("user@example.com") + department = Department.objects.create(name="Support") + api_client.force_authenticate(user=user) + + chat = Chat.objects.create( + type="comments", + name=f"Комментарии: {department.name}", + created_by=user, + context_content_type=ContentType.objects.get_for_model(Department), + context_object_id=department.id, + ) + + response = api_client.get(discussion_chat_url(department.id)) + + assert response.status_code == status.HTTP_200_OK + chat.refresh_from_db() + assert chat.created_by is None + assert chat.name == department.name + assert chat.flags.get("show_in_messages") is True + + +def test_any_active_user_can_retrieve_department_comments_chat( + api_client: APIClient, +): + department = Department.objects.create(name="HR") + member = make_user("member@example.com") + outsider = make_user("outsider@example.com") + EmployeeDepartment.objects.create( + employee=member, + department=department, + is_active=True, + ) + + chat = create_comment( + department, + author=member, + content="Первое сообщение", + ).chat + + api_client.force_authenticate(user=outsider) + response = api_client.get(f"/api/v1/communications/chats/{chat.id}/") + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == chat.id + assert response.data["type"] == "comments" + + +def test_any_active_user_can_send_message_to_department_comments_chat( + api_client: APIClient, +): + department = Department.objects.create(name="Accounting") + member = make_user("member@example.com") + outsider = make_user("outsider@example.com") + EmployeeDepartment.objects.create( + employee=member, + department=department, + is_active=True, + ) + + chat = create_comment( + department, + author=member, + content="Старт обсуждения", + ).chat + + api_client.force_authenticate(user=outsider) + bootstrap_response = api_client.get(discussion_chat_url(department.id)) + assert bootstrap_response.status_code == status.HTTP_200_OK + chat.refresh_from_db() + assert chat.created_by is None + assert chat.name == department.name + + response = api_client.post( + "/api/v1/communications/messages/upload/", + {"chat_id": chat.id, "content": "Пишу в чат отдела"}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert Message.objects.filter( + chat=chat, + author=outsider, + content="Пишу в чат отдела", + ).exists() + chat.refresh_from_db() + assert chat.created_by is None + assert chat.name == department.name + assert chat.flags.get("show_in_messages") is True + + +def test_department_comment_notifications_keep_chat_routing_and_include_role_only( + monkeypatch, +): + department = Department.objects.create(name="Backoffice") + member = make_user("member@example.com") + role_only = make_user("role-only@example.com") + previous_commenter = make_user("previous@example.com") + + EmployeeDepartment.objects.create( + employee=member, + department=department, + is_active=True, + ) + dept_role = DepartmentRole.objects.create( + department=department, + name="Consultant", + ) + RoleAssignment.objects.create( + employee=role_only, + role=dept_role, + is_active=True, + ) + + create_comment( + department, + author=previous_commenter, + content="Первый комментарий", + ) + + sent_notifications = [] + + def capture_notification(**kwargs): + sent_notifications.append(kwargs) + + monkeypatch.setattr( + "communications.notifications.handlers._send_notification", + capture_notification, + ) + + message = create_comment( + department, + author=member, + content="Новый комментарий", + ) + + recipient_ids = {payload["recipient"].id for payload in sent_notifications} + + assert role_only.id in recipient_ids + assert previous_commenter.id in recipient_ids + assert member.id not in recipient_ids + assert sent_notifications + for payload in sent_notifications: + assert payload["action_url"] == f"/messages/{message.chat_id}" + assert payload["data"]["chat_id"] == message.chat_id + + +def test_department_chat_visible_in_messages_list_but_generic_comments_hidden( + api_client: APIClient, +): + user = make_user("user@example.com") + department = Department.objects.create(name="Finance") + api_client.force_authenticate(user=user) + + visible_chat_response = api_client.get(discussion_chat_url(department.id)) + assert visible_chat_response.status_code == status.HTTP_200_OK + visible_chat_id = visible_chat_response.json()["chat_id"] + + hidden_comments_chat = Chat.objects.create( + type="comments", + name="Скрытый object chat", + ) + + response = api_client.get("/api/v1/communications/chats/") + + assert response.status_code == status.HTTP_200_OK + payload = response.data + items = payload["results"] if isinstance(payload, dict) and "results" in payload else payload + returned_ids = {item["id"] for item in items} + assert visible_chat_id in returned_ids + assert hidden_comments_chat.id not in returned_ids diff --git a/backend/tests/api/v1/employees/test_department_user_perms.py b/backend/tests/api/v1/employees/test_department_user_perms.py new file mode 100644 index 00000000..d4a0306b --- /dev/null +++ b/backend/tests/api/v1/employees/test_department_user_perms.py @@ -0,0 +1,80 @@ +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from employees.constants import DeptPerm +from employees.models import ( + Department, + DepartmentPermission, + DepartmentRole, + RoleAssignment, +) +from tests.conftest import _unique_phone +from tests.test_config import DEFAULT_PASSWORD + +User = get_user_model() + + +def _unique_email() -> str: + return f"user{User.objects.count()}@example.com" + + +def make_user(*, staff: bool = False, superuser: bool = False) -> User: + user = User.objects.create_user( + email=_unique_email(), + password=DEFAULT_PASSWORD, + phone_number=_unique_phone(), + send_activation_email=False, + first_name="Dept", + last_name="User", + ) + user.is_staff = staff + user.is_superuser = superuser + user.email_verified = True + user.is_active = True + user.save( + update_fields=["is_staff", "is_superuser", "email_verified", "is_active"] + ) + return user + + +@pytest.fixture() +def api_client() -> APIClient: + return APIClient() + + +@pytest.mark.django_db +def test_department_user_perms_includes_feed_flags_for_head(api_client: APIClient): + head = make_user() + dept = Department.objects.create(name="Бухгалтерия", head=head) + + api_client.force_authenticate(user=head) + url = reverse("api:v1:departments-user-perms", args=[dept.id]) + resp = api_client.get(url) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["can_publish_posts"] is True + assert resp.json()["can_manage_feed"] is True + + +@pytest.mark.django_db +def test_department_user_perms_resolves_role_based_feed_rights(api_client: APIClient): + employee = make_user() + dept = Department.objects.create(name="Закупки") + role = DepartmentRole.objects.create(department=dept, name="Редактор ленты") + publish_perm, _ = DepartmentPermission.objects.get_or_create( + code=DeptPerm.CREATE_POST, + defaults={"name": "Публиковать новости на странице отдела"}, + ) + role.scoped_permissions.add(publish_perm) + RoleAssignment.objects.create(employee=employee, role=role, is_active=True) + + api_client.force_authenticate(user=employee) + url = reverse("api:v1:departments-user-perms", args=[dept.id]) + resp = api_client.get(url) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["can_publish_posts"] is True + assert resp.json()["can_manage_feed"] is False diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py new file mode 100644 index 00000000..5a6611c2 --- /dev/null +++ b/backend/tests/api/v1/feed/test_posts.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from employees.constants import DeptPerm +from employees.models import ( + DepartmentPermission, + DepartmentRole, + EmployeeDepartment, + RoleAssignment, +) +from feed.models import Post +from rest_framework import status + +from tests.test_config import API_POSTS_URL + +pytestmark = pytest.mark.django_db + + +def _department_post_payload(department_id: int, **extra): + payload = { + "type": "department", + "department": department_id, + "title": "Новость отдела", + "body": "Важное объявление", + } + payload.update(extra) + return payload + + +def _grant_department_post_role(user, department): + permission, _ = DepartmentPermission.objects.get_or_create( + code=DeptPerm.CREATE_POST, + defaults={"name": "Публиковать новости на странице отдела"}, + ) + role = DepartmentRole.objects.create( + department=department, + name=f"Publisher {user.id}", + ) + role.scoped_permissions.add(permission) + RoleAssignment.objects.create( + employee=user, + role=role, + is_active=True, + ) + return role + + +def test_create_department_post_notifies_active_members_and_role_only( + monkeypatch, + auth_client_factory, + make_user, + department_factory, +): + author = make_user("author@example.com") + department = department_factory(name="Finance", head=author) + member = make_user("member@example.com") + role_only = make_user("roleonly@example.com") + inactive_member = make_user("inactive@example.com", active=False) + + EmployeeDepartment.objects.create( + employee=member, + department=department, + is_active=True, + ) + EmployeeDepartment.objects.create( + employee=inactive_member, + department=department, + is_active=True, + ) + + role = DepartmentRole.objects.create( + department=department, + name="Observer", + ) + RoleAssignment.objects.create( + employee=role_only, + role=role, + is_active=True, + ) + + captured = [] + + def _capture_notify(*args, **kwargs): + captured.append(kwargs) + + monkeypatch.setattr("feed.notifications.handlers.notify.send", _capture_notify) + + client = auth_client_factory(author) + response = client.post( + API_POSTS_URL, + _department_post_payload(department.id), + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + post = Post.objects.get(id=response.data["id"]) + assert post.department_id == department.id + + recipient_ids = {payload["recipient"].id for payload in captured} + assert member.id in recipient_ids + assert role_only.id in recipient_ids + assert inactive_member.id not in recipient_ids + assert author.id not in recipient_ids + + +def test_create_department_post_with_attachment_and_empty_body( + auth_client_factory, + make_user, + department_factory, +): + author = make_user("head@example.com") + department = department_factory(name="HR", head=author) + attachment = SimpleUploadedFile( + "memo.txt", + b"department announcement", + content_type="text/plain", + ) + + client = auth_client_factory(author) + response = client.post( + API_POSTS_URL, + _department_post_payload( + department.id, + body="", + attachment=attachment, + ), + format="multipart", + ) + + assert response.status_code == status.HTTP_201_CREATED + post = Post.objects.get(id=response.data["id"]) + assert post.attachment.name + assert post.body == "" + + +def test_create_department_post_requires_department_not_403( + auth_client_factory, + make_user, +): + author = make_user("author@example.com", staff=True) + client = auth_client_factory(author) + + response = client.post( + API_POSTS_URL, + { + "type": "department", + "title": "Без отдела", + "body": "Тело", + }, + format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "department" in response.data + + +def test_department_post_requires_publish_permission( + auth_client_factory, + make_user, + department_factory, +): + user = make_user("plain@example.com") + department = department_factory(name="Legal") + client = auth_client_factory(user) + + response = client.post( + API_POSTS_URL, + _department_post_payload(department.id), + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_role_assignment_can_create_department_post( + auth_client_factory, + make_user, + department_factory, +): + user = make_user("publisher@example.com") + department = department_factory(name="Accounting") + _grant_department_post_role(user, department) + + client = auth_client_factory(user) + response = client.post( + API_POSTS_URL, + _department_post_payload(department.id), + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + assert Post.objects.filter(id=response.data["id"], department=department).exists() + + +def test_list_department_posts_filtered_by_department( + auth_client_factory, + make_user, + department_factory, +): + viewer = make_user("viewer@example.com") + department_a = department_factory(name="Dept A") + department_b = department_factory(name="Dept B") + + Post.objects.create( + author=viewer, + department=department_a, + type="department", + title="A1", + body="one", + ) + Post.objects.create( + author=viewer, + department=department_b, + type="department", + title="B1", + body="two", + ) + + client = auth_client_factory(viewer) + response = client.get(API_POSTS_URL, {"type": "department", "department": department_a.id}) + + assert response.status_code == status.HTTP_200_OK + payload = response.data["results"] if isinstance(response.data, dict) else response.data + assert len(payload) == 1 + assert payload[0]["department_id"] == department_a.id + + +def test_department_pin_scope_is_separate_from_global_feed( + auth_client_factory, + make_user, + department_factory, +): + moderator = make_user("moderator@example.com", staff=True) + department = department_factory(name="Finance") + post = Post.objects.create( + author=moderator, + department=department, + type="department", + title="Scoped pin", + body="body", + ) + + client = auth_client_factory(moderator) + response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=department") + + assert response.status_code == status.HTTP_200_OK + post.refresh_from_db() + assert post.pinned_department is True + assert post.pinned_global is False + + global_list = client.get(API_POSTS_URL) + global_payload = ( + global_list.data["results"] + if isinstance(global_list.data, dict) + else global_list.data + ) + scoped_global = next(item for item in global_payload if item["id"] == post.id) + assert scoped_global["pinned"] is False + assert scoped_global["pinned_global"] is False + assert scoped_global["pinned_department"] is True + + department_list = client.get( + API_POSTS_URL, + { + "type": "department", + "department": department.id, + "pin_scope": "department", + }, + ) + department_payload = ( + department_list.data["results"] + if isinstance(department_list.data, dict) + else department_list.data + ) + scoped_department = next( + item for item in department_payload if item["id"] == post.id + ) + assert scoped_department["pinned"] is True + + +def test_global_pin_scope_is_separate_from_department_feed( + auth_client_factory, + make_user, + department_factory, +): + moderator = make_user("chief@example.com", staff=True) + department = department_factory(name="HR") + post = Post.objects.create( + author=moderator, + department=department, + type="department", + title="Global scoped pin", + body="body", + ) + + client = auth_client_factory(moderator) + response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=global") + + assert response.status_code == status.HTTP_200_OK + post.refresh_from_db() + assert post.pinned_global is True + assert post.pinned_department is False + + department_list = client.get( + API_POSTS_URL, + { + "type": "department", + "department": department.id, + "pin_scope": "department", + }, + ) + department_payload = ( + department_list.data["results"] + if isinstance(department_list.data, dict) + else department_list.data + ) + scoped_department = next( + item for item in department_payload if item["id"] == post.id + ) + assert scoped_department["pinned"] is False + + +def test_department_pin_scope_rejected_for_non_department_post( + auth_client_factory, + make_user, +): + moderator = make_user("staff@example.com", staff=True) + post = Post.objects.create( + author=moderator, + type="company", + title="Company post", + body="body", + ) + + client = auth_client_factory(moderator) + response = client.post(f"{API_POSTS_URL}{post.id}/pin/?scope=department") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "detail" in response.data diff --git a/backend/tests/realtime/test_integration.py b/backend/tests/realtime/test_integration.py index b43c72f4..27ae9404 100644 --- a/backend/tests/realtime/test_integration.py +++ b/backend/tests/realtime/test_integration.py @@ -6,8 +6,11 @@ import pytest from channels.layers import get_channel_layer from channels.testing import WebsocketCommunicator +from channels.db import database_sync_to_async +from django.contrib.contenttypes.models import ContentType from communications.models import Chat, ChatMembership, Message +from employees.models import Department pytestmark = pytest.mark.django_db(transaction=True) @@ -134,8 +137,53 @@ class TestRealtimeIntegration: # Должны получить обновление списка (list_update) update = await communicator.receive_json_from(timeout=5) - + assert update["type"] == "list_update" assert update["chat_id"] == test_chat.id await communicator.disconnect() + + async def test_visible_department_comments_chat_updates_list_for_active_user( + self, ws_communicator, user + ): + """ + Видимый comments-chat отдела должен попадать в realtime-подписки + и присылать list_update для страницы списка чатов. + """ + department = await database_sync_to_async(Department.objects.create)( + name="Finance", + ) + department_ct = await database_sync_to_async(ContentType.objects.get_for_model)(Department) + comments_chat = await database_sync_to_async(Chat.objects.create)( + type="comments", + name=department.name, + flags={"show_in_messages": True}, + context_content_type=department_ct, + context_object_id=department.id, + ) + + communicator = await ws_communicator(user=user) + + connected, _ = await communicator.connect() + assert connected + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f"chat_{comments_chat.id}", + { + "type": "chat_message", + "chat_id": comments_chat.id, + "payload": { + "id": 1, + "content": "Department chat message", + "author_id": user.id, + }, + }, + ) + + update = await communicator.receive_json_from(timeout=5) + + assert update["type"] == "list_update" + assert update["chat_id"] == comments_chat.id + + await communicator.disconnect() diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md index cee778bd..43838df5 100644 --- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -233,13 +233,14 @@ - detail screen, который должен уметь переключаться между `showcase` и `management` без смены route и без ощущения двух разных страниц - page-level controller для domain actions вокруг одного объекта: overview, members и inline management actions +- entry-point с detail page в уже существующий object-chat, когда объекту нужен access к реальной message history без локального duplicate UI - richer detail layout, где есть сильный header и один основной roster/workspace-блок, без раздувания служебных summary-panels и вторичных management-panels - member roster с role assignment и person-chip подачей вместо сырых строк участников - role management на уровне названий ролей и назначений участникам, встроенный в roster, без фронтового permission-editor С оговоркой: -- `departments/[id]` должен оставаться привязанным к реальным backend-capabilities; не наращивать UI, если под него нет соответствующих endpoint/actions +- `departments/[id]` должен оставаться привязанным к реальным backend-capabilities; если у отдела уже есть живой object-chat, не дублировать его локальной discussion-панелью на странице ### Messages diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index 4a72b785..20267ae4 100644 --- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -34,7 +34,7 @@ | `users/directory` | `mixed` | `users/page.tsx` и `employees/page.tsx` уже живут в app-surface language, сценарии user/employee navigation выровнены и читаются как один cluster | список людей всё ещё проще и беднее по interaction density, чем основные рабочие модули, поэтому это не полноценный stylistic reference | считать baseline-pass завершённым и трогать дальше только при появлении richer directory requirements | | `home/feed` | `mixed` | сильный app-shell fit, хорошая operational density, уже много app primitives | page очень большой, комментарии и post-compose logic перегружены, встречаются legacy `rounded-md` fragments | использовать как reference по feed density, но разрезать modal/comments/post flows и дочистить geometry | | `notifications` | `mixed` | близок к operational app-style, фильтры и list hierarchy уже читаются системно | page-level logic пока монолитная, нет выделенного feature controller, есть место для уплотнения и симметрии с messages/requests | после people cluster сделать короткий structural pass без крупного редизайна | -| `departments` | `mixed` | detail screen уже работает как dual-mode page: showcase-first для обычного просмотра и встроенный inline management-layer для управленцев, page-level controller вынесен в hook | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-detail pages с mixed audience и затем при желании подтянуть list screen до той же richness | +| `departments` | `mixed` | detail screen уже работает как dual-mode page: showcase-first для обычного просмотра и встроенный inline management-layer для управленцев, page-level controller вынесен в hook, вход в discussion идёт через реальный object-chat отдела | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-detail pages с mixed audience и object-chat entry points; list screen при желании подтянуть до той же richness | | `auth/legal` | `mixed` | изолированные flows уже отделены от app-shell, общая подача аккуратная | `rounded-[2rem]` и локальная hero-card геометрия образуют мини-систему отдельно от app shell | не смешивать с operational refactor; сделать отдельный compact pass по auth/legal geometry | | `documents/dashboard` | `legacy-heavy` | есть готовые data blocks | заметны старые rounded patterns и мелкие utility-решения | включить в documents refactor pass | diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index 2ad882fc..e37aedbf 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -6,12 +6,12 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } import { ArrowLeft, BadgePlus, - Building2, Check, ChevronDown, ChevronRight, Crown, Link2, + MessageCircle, PencilLine, Plus, Search, @@ -22,6 +22,7 @@ import { } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import { DepartmentFeedSection } from "@/components/departments/DepartmentFeedSection"; import { DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME, getDepartmentMembersListClassName, @@ -38,8 +39,10 @@ import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import { Modal } from "@/components/ui/Modal"; import { useDepartmentPage } from "@/hooks/useDepartmentPage"; +import { apiClient } from "@/lib/api"; import { displayUserName, userProfileLink } from "@/lib/shared"; import type { DepartmentMemberLink, User } from "@/types/api"; +import { toast } from "sonner"; type DepartmentMemberModalMode = "add" | "assignRole"; @@ -409,7 +412,7 @@ function DepartmentMembersEmptyState() { Участники не найдены

- Измени поисковый запрос. + В этом отделе пока нет состава.

); @@ -734,10 +737,12 @@ export default function DepartmentDetailPage() { const departmentId = Number(params?.id); const h = useDepartmentPage(departmentId); const [managementMode, setManagementMode] = useState(false); + const [membersExpanded, setMembersExpanded] = useState(false); const [roleMenuOpenForId, setRoleMenuOpenForId] = useState(null); const roleMenuRef = useRef(null); const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false); const departmentMenuRef = useRef(null); + const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false); const activeMembersCount = h.members.filter((member) => member.is_active).length; const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name })); @@ -748,6 +753,12 @@ export default function DepartmentDetailPage() { const isManagementMode = canUseManagementMode && managementMode; + useEffect(() => { + if (isManagementMode) { + setMembersExpanded(true); + } + }, [isManagementMode]); + useEffect(() => { if (roleMenuOpenForId === null) return; @@ -794,6 +805,22 @@ export default function DepartmentDetailPage() { }; }, [departmentMenuOpen]); + const openDepartmentChat = async () => { + if (!h.department) return; + + try { + setOpeningDepartmentChat(true); + const response = await apiClient.getDepartmentDiscussionChat(h.department.id) as { + chat_id: number; + }; + router.push(`/messages/${response.chat_id}`); + } catch { + toast.error("Не удалось открыть чат отдела"); + } finally { + setOpeningDepartmentChat(false); + } + }; + return (
@@ -817,178 +844,232 @@ export default function DepartmentDetailPage() { ) : h.department ? ( <>
-
-
-
- - - Отдел - - - - {activeMembersCount} участников - - {h.roles.length} ролей - {isManagementMode ? ( - Режим управления - ) : null} +
+
+
+

Отдел

+
+ + + {activeMembersCount} участников + + {h.roles.length} ролей + {isManagementMode ? ( + Режим управления + ) : null} +
+ {(canUseManagementMode || h.userPerms.can_manage) ? ( +
+ + + {departmentMenuOpen ? ( +
+ {h.userPerms.can_manage ? ( + + ) : null} + + {h.userPerms.can_assign_roles ? ( + + ) : null} + + {h.userPerms.can_assign_roles ? ( + + ) : null} + + {h.userPerms.can_manage ? ( + + ) : null} + + {canUseManagementMode ? ( + + ) : null} + + {h.userPerms.can_manage ? ( + <> +
+ + + ) : null} +
+ ) : null} +
+ ) : null} +
+

{h.department.name}

{h.department.description}

-
- {(canUseManagementMode || h.userPerms.can_manage) ? ( -
+
+
+
+
+ +
+
+
+

+ Сотрудники отдела +

+

+ Состав отдела и назначенные роли. +

+
+ {h.filteredMembers.length ? ( + + ) : null} +
- {departmentMenuOpen ? ( -
- {h.userPerms.can_manage ? ( - - ) : null} - - {h.userPerms.can_assign_roles ? ( - - ) : null} - - {h.userPerms.can_assign_roles ? ( - - ) : null} - - {h.userPerms.can_manage ? ( - - ) : null} - - {canUseManagementMode ? ( - - ) : null} - - {h.userPerms.can_manage ? ( - <> -
- - - ) : null} -
- ) : null} + {membersExpanded || !h.filteredMembers.length ? ( +
+ {h.filteredMembers.length ? ( + h.filteredMembers.map((member) => ( + + )) + ) : ( + + )}
) : null} -
- -
- {h.filteredMembers.length ? ( - h.filteredMembers.map((member) => ( - - )) - ) : ( - - )} -
- +
+ + + ) : null}
diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 69bf43e6..e91ff3e5 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -35,6 +35,7 @@ const chatTypeOptions = [ { key: "all", label: "Все" }, { key: "global", label: "Глобальный" }, { key: "channel", label: "Каналы" }, + { key: "comments", label: "Отделы" }, { key: "private", label: "Личные" }, { key: "group", label: "Группы" }, { key: "announcement", label: "Объявления" }, @@ -278,6 +279,7 @@ export default function MessagesPage() { all: { total: chats.length, unread: 0 }, global: { total: 0, unread: 0 }, channel: { total: 0, unread: 0 }, + comments: { total: 0, unread: 0 }, private: { total: 0, unread: 0 }, group: { total: 0, unread: 0 }, announcement: { total: 0, unread: 0 }, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 47ab5118..fb90463b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,11 +1,14 @@ "use client"; -import { ChevronRight, Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Pin, Plus, Send, Trash2, X } from "lucide-react"; +import { ChevronRight, Pencil, Pin, Plus, Trash2 } from "lucide-react"; import { AppShell } from "../components/AppShell"; +import { FeedPostCard } from "@/components/feed/FeedPostCard"; import { Modal } from "@/components/ui"; +import { PostCommentsModal } from "@/components/feed/PostCommentsModal"; +import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; -import { Suspense, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import type { Comment, Post } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; @@ -49,19 +52,6 @@ function HomePageContent() { const [commentsOpen, setCommentsOpen] = useState(false); const [activePost, setActivePost] = useState(null); - const [comments, setComments] = useState([]); - const [commentsLoading, setCommentsLoading] = useState(false); - const [commentsError, setCommentsError] = useState(null); - const [newComment, setNewComment] = useState(""); - const [commentSending, setCommentSending] = useState(false); - const [editingCommentId, setEditingCommentId] = useState(null); - const [editingCommentText, setEditingCommentText] = useState(""); - const [commentImage, setCommentImage] = useState(null); - const [commentAttachment, setCommentAttachment] = useState(null); - const commentImageRef = useRef(null); - const commentAttachmentRef = useRef(null); - const [commentActionId, setCommentActionId] = useState(null); - const commentsBottomRef = useRef(null); const [createPostOpen, setCreatePostOpen] = useState(false); const [createType, setCreateType] = useState<"company" | "department">("company"); const [createDepartmentId, setCreateDepartmentId] = useState(""); @@ -113,6 +103,7 @@ function HomePageContent() { const closeCommentsModal = () => { setCommentsOpen(false); + setActivePost(null); clearPostParam(); }; @@ -147,7 +138,7 @@ function HomePageContent() { hasPermission("employees.manage_feed") ); const canCreatePost = canCreateCompanyPost || canCreateDepartmentPost || ((user?.departments?.length || 0) > 0); - const userDepartments = user?.departments || []; + const userDepartments = useMemo(() => user?.departments || [], [user?.departments]); const canManageAnyPost = Boolean( auth?.is_staff || auth?.is_superuser || @@ -164,12 +155,12 @@ function HomePageContent() { hasPermission("pin_post") ); - const sortPostsPinnedFirst = (items: Post[]) => [...items].sort((left, right) => { + const sortPostsPinnedFirst = useCallback((items: Post[]) => [...items].sort((left, right) => { const leftPinned = left.pinned ? 1 : 0; const rightPinned = right.pinned ? 1 : 0; if (leftPinned !== rightPinned) return rightPinned - leftPinned; return new Date(right.created_at).getTime() - new Date(left.created_at).getTime(); - }); + }), []); const canEditPost = (post: Post) => { if (canManageAnyPost) return true; @@ -190,19 +181,10 @@ function HomePageContent() { return canDeleteAnyComments; }; - const scrollCommentsToBottom = (smooth = true) => { - requestAnimationFrame(() => { - commentsBottomRef.current?.scrollIntoView({ - behavior: smooth ? "smooth" : "auto", - block: "end", - }); - }); - }; - - const refreshPosts = async () => { + const refreshPosts = useCallback(async () => { const response = await apiClient.getPosts(); setPosts(sortPostsPinnedFirst(response.results)); - }; + }, [sortPostsPinnedFirst]); useEffect(() => { async function loadPosts() { @@ -216,25 +198,7 @@ function HomePageContent() { } } loadPosts(); - }, []); - - useEffect(() => { - if (!commentsOpen && !createPostOpen) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - closeCommentsModal(); - } - }; - - const previous = document.body.style.overflow; - document.body.style.overflow = "hidden"; - window.addEventListener("keydown", onKeyDown); - - return () => { - document.body.style.overflow = previous; - window.removeEventListener("keydown", onKeyDown); - }; - }, [commentsOpen, createPostOpen]); + }, [refreshPosts]); useEffect(() => { if (postMenuOpenId === null) return; @@ -267,11 +231,6 @@ function HomePageContent() { setCreateDepartmentId(String(userDepartments[0].id)); }, [createType, createDepartmentId, userDepartments]); - useEffect(() => { - if (!commentsOpen || commentsLoading) return; - scrollCommentsToBottom(false); - }, [commentsOpen, commentsLoading, comments.length]); - const handleLikeToggle = async (post: Post) => { if (likeBusyId === post.id) return; @@ -327,11 +286,20 @@ function HomePageContent() { try { const response = currentlyPinned - ? await apiClient.unpinPost(post.id) - : await apiClient.pinPost(post.id); + ? await apiClient.unpinPost(post.id, "global") + : await apiClient.pinPost(post.id, "global"); setPosts((prev) => sortPostsPinnedFirst( - prev.map((item) => item.id === post.id ? { ...item, pinned: Boolean(response?.pinned) } : item) + prev.map((item) => + item.id === post.id + ? { + ...item, + pinned: Boolean(response?.pinned), + pinned_global: Boolean(response?.pinned_global), + pinned_department: Boolean(response?.pinned_department), + } + : item + ) )); } catch (err) { console.error("Ошибка закрепления публикации:", err); @@ -343,26 +311,6 @@ function HomePageContent() { } }; - const loadComments = async (postId: number) => { - setCommentsLoading(true); - setCommentsError(null); - try { - const response = await apiClient.getComments({ post: postId }); - const items = Array.isArray(response) - ? response - : Array.isArray(response?.results) - ? response.results - : []; - setComments(items); - } catch (err) { - console.error("Ошибка загрузки комментариев:", err); - setCommentsError("Не удалось загрузить комментарии"); - setComments([]); - } finally { - setCommentsLoading(false); - } - }; - const openLikesPopover = async (postId: number, likesCount: number) => { setLikesPopoverPostId(postId); if (!likesCount) { @@ -391,13 +339,30 @@ function HomePageContent() { } }; - const openComments = async (post: Post) => { + const openComments = (post: Post) => { setActivePost(post); setCommentsOpen(true); - setNewComment(""); - setEditingCommentId(null); - setEditingCommentText(""); - await loadComments(post.id); + }; + + const applyCommentCountDelta = (postId: number, delta: number) => { + setPosts((prev) => + prev.map((item) => + item.id === postId + ? { + ...item, + comments_count: Math.max(0, (item.comments_count || 0) + delta), + } + : item + ) + ); + setActivePost((prev) => + prev && prev.id === postId + ? { + ...prev, + comments_count: Math.max(0, (prev.comments_count || 0) + delta), + } + : prev + ); }; useEffect(() => { @@ -407,101 +372,9 @@ function HomePageContent() { const targetPost = posts.find((post) => post.id === linkedPostId); if (!targetPost) return; - void openComments(targetPost); + openComments(targetPost); }, [activePost?.id, commentsOpen, linkedPostId, posts]); - const handleCreateComment = async () => { - if (!activePost) return; - const text = newComment.trim(); - if (!text && !commentImage && !commentAttachment) return; - - setCommentSending(true); - try { - const created = await apiClient.createComment( - activePost.id, - text || " ", - commentImage || undefined, - commentAttachment || undefined - ); - setComments((prev) => [...prev, created]); - setNewComment(""); - setCommentImage(null); - setCommentAttachment(null); - setPosts((prev) => - prev.map((p) => - p.id === activePost.id - ? { ...p, comments_count: (p.comments_count || 0) + 1 } - : p - ) - ); - setActivePost((prev) => - prev ? { ...prev, comments_count: (prev.comments_count || 0) + 1 } : prev - ); - scrollCommentsToBottom(); - } catch (err) { - console.error("Ошибка отправки комментария:", err); - } finally { - setCommentSending(false); - } - }; - - const startEditComment = (comment: Comment) => { - setEditingCommentId(comment.id); - setEditingCommentText((comment.text || comment.content || "").trim()); - }; - - const cancelEditComment = () => { - setEditingCommentId(null); - setEditingCommentText(""); - }; - - const saveEditComment = async (commentId: number) => { - const text = editingCommentText.trim(); - if (!text) return; - - setCommentActionId(commentId); - try { - const updated = await apiClient.updateComment(commentId, text); - setComments((prev) => prev.map((c) => (c.id === commentId ? { ...c, ...updated } : c))); - cancelEditComment(); - } catch (err) { - console.error("Ошибка редактирования комментария:", err); - } finally { - setCommentActionId(null); - } - }; - - const removeComment = async (comment: Comment) => { - if (!activePost) return; - - setCommentActionId(comment.id); - try { - await apiClient.deleteComment(comment.id); - setComments((prev) => prev.filter((c) => c.id !== comment.id)); - - setPosts((prev) => - prev.map((p) => - p.id === activePost.id - ? { ...p, comments_count: Math.max(0, (p.comments_count || 0) - 1) } - : p - ) - ); - setActivePost((prev) => - prev - ? { ...prev, comments_count: Math.max(0, (prev.comments_count || 0) - 1) } - : prev - ); - - if (editingCommentId === comment.id) { - cancelEditComment(); - } - } catch (err) { - console.error("Ошибка удаления комментария:", err); - } finally { - setCommentActionId(null); - } - }; - const openCreatePostModal = () => { setCreateError(null); setEditingPostId(null); @@ -630,15 +503,6 @@ function HomePageContent() { ) : ( posts.map((post) => { - const authorName = post.author - ? `${post.author.last_name} ${post.author.first_name}`.trim() - : 'Аноним'; - const authorInitials = post.author - ? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}` - : 'А'; - - const postText = (post.body || post.content || "").trim(); - // Форматируем дату const postDate = new Date(post.created_at); const now = new Date(); @@ -651,36 +515,12 @@ function HomePageContent() { : `${Math.floor(diffHours / 24)} дн. назад`; return ( -
-
-
-
-
- {post.author?.avatar ? ( - {authorName} - ) : ( - authorInitials - )} -
-
-
-

{authorName}

-
- {timeAgo} - {post.pinned ? ( - - - - ) : null} -
-
-
- - {(canEditPost(post) || canDeletePost(post)) ? ( + {timeAgo}} + headerActions={( + (canEditPost(post) || canDeletePost(post)) ? (
- {post.pinned ? "Открепить" : "Закрепить"} + {post.pinned ? "Открепить в общей ленте" : "Закрепить в общей ленте"} ) : null} {canEditPost(post) ? ( @@ -726,7 +566,6 @@ function HomePageContent() { Редактировать ) : null} - {canDeletePost(post) ? (
- ) : null} -
- {post.title ? ( -

{post.title}

- ) : null} - {postText ? ( -

{postText}

- ) : null} - {post.image && ( -
- -
+ ) : null )} - {(post.attachment || post.attachment_url) && ( - - )} -
-
openLikesPopover(post.id, post.likes_count || 0)} - onMouseLeave={() => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev))} - > - - - {likesPopoverPostId === post.id ? ( -
-

Лайкнули

- {likesLoadingPostId === post.id ? ( -

Загрузка...

- ) : likesUsersEndpointUnavailable ? ( -

Список лайкнувших временно недоступен

- ) : (likesUsersMap[post.id] || []).length === 0 ? ( -

Пока нет лайков

- ) : ( -
- {(likesUsersMap[post.id] || []).map((u) => { - const name = formatUserName(u); - const initials = formatInitials(u.first_name, u.last_name); - - return ( -
-
- {u.avatar ? ( - {name} - ) : ( - initials - )} -
-

{name}

-
- ); - })} -
- )} + imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)} + isLikeActive={Boolean(post.is_liked)} + likeDisabled={likeBusyId === post.id} + likesPopover={likesPopoverPostId === post.id ? ( +
+

Лайкнули

+ {likesLoadingPostId === post.id ? ( +

Загрузка...

+ ) : likesUsersEndpointUnavailable ? ( +

Список лайкнувших временно недоступен

+ ) : (likesUsersMap[post.id] || []).length === 0 ? ( +

Пока нет лайков

+ ) : ( +
+ {(likesUsersMap[post.id] || []).map((u) => { + const name = formatUserName(u); + const initials = formatInitials(u.first_name, u.last_name); + return ( +
+ +

{name}

+
+ ); + })}
- ) : null} + )}
- -
-
+ ) : null} + likesWrapperProps={{ + onMouseEnter: () => openLikesPopover(post.id, post.likes_count || 0), + onMouseLeave: () => setLikesPopoverPostId((prev) => (prev === post.id ? null : prev)), + }} + onLikeToggle={handleLikeToggle} + onOpenComments={openComments} + pinMarkerTitle="Закреплено в общей ленте" + pinnedStyle="inline" + /> ); }) )} @@ -954,196 +747,15 @@ function HomePageContent() { - - {(commentImage || commentAttachment) && ( -
- {commentImage && ( -
- - {commentImage.name} - -
- )} - {commentAttachment && ( -
- - {commentAttachment.name} - -
- )} -
- )} -
- { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} /> - { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} /> - - - setNewComment(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleCreateComment(); - } - }} - placeholder="Напишите комментарий..." - className="app-input order-4 col-span-3 min-w-0 h-10 w-full rounded-lg px-3 text-sm sm:order-3 sm:col-span-1" - /> - -
- - } - > - {activePost && ( - <> -

Публикация #{activePost.id}

-
- {commentsLoading ? ( -

Загрузка комментариев...

- ) : commentsError ? ( -

{commentsError}

- ) : comments.length === 0 ? ( -

Комментариев пока нет

- ) : ( -
- {comments.map((comment) => { - const commentText = (comment.text || comment.content || "").trim(); - const authorName = comment.author - ? `${comment.author.last_name || ""} ${comment.author.first_name || ""}`.trim() || "Пользователь" - : "Пользователь"; - const authorInitials = comment.author - ? `${comment.author.last_name?.[0] || ""}${comment.author.first_name?.[0] || ""}` || "П" - : "П"; - - return ( -
-
-
- {comment.author?.avatar ? ( - {authorName} - ) : ( - authorInitials - )} -
-
-
-

{authorName}

-
- {canEditComment(comment) ? ( - - ) : null} - - {canDeleteComment(comment) ? ( - - ) : null} -
-
- - {editingCommentId === comment.id ? ( -
-