diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py
index bf036c6b..0bf96626 100644
--- a/backend/api/v1/employees/views/departments.py
+++ b/backend/api/v1/employees/views/departments.py
@@ -40,6 +40,7 @@ from rest_framework.response import Response
from ...permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
+ can_publish_department_posts,
has_dept_perm,
)
from ..serializers import (
@@ -500,8 +501,8 @@ 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_publish_posts": can_publish_department_posts(
+ request.user, dept.id
),
"can_manage_feed": has_dept_perm(
request.user, dept.id, DeptPerm.MANAGE_FEED
diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py
index 20a2c185..8b46e34f 100644
--- a/backend/api/v1/feed/serializers.py
+++ b/backend/api/v1/feed/serializers.py
@@ -43,6 +43,11 @@ class PostLikerSerializer(AuthorMiniSerializer):
class PostListSerializer(serializers.ModelSerializer):
author_id = serializers.IntegerField(read_only=True)
author = AuthorMiniSerializer(read_only=True)
+ department_name = serializers.CharField(
+ source="department.name",
+ read_only=True,
+ allow_null=True,
+ )
# удoбные представления дат
created_at_display = serializers.DateTimeField(
@@ -71,6 +76,7 @@ class PostListSerializer(serializers.ModelSerializer):
"type",
"department",
"department_id",
+ "department_name",
"title",
"body",
"image",
@@ -97,6 +103,7 @@ class PostListSerializer(serializers.ModelSerializer):
"author_id",
"author",
"department_id",
+ "department_name",
"is_liked",
)
diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py
index 0d3dd9a6..698da8c4 100644
--- a/backend/api/v1/feed/views.py
+++ b/backend/api/v1/feed/views.py
@@ -30,6 +30,7 @@ from rest_framework.response import Response
from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
+ can_publish_department_posts,
)
from .serializers import PostListSerializer, PostLikerSerializer, PostSerializer
@@ -57,8 +58,9 @@ class PostViewSet(viewsets.ModelViewSet):
- create:
* type=company → AdminOrActionOrModelPerms
+ required_perm_code="feed.publish_company_post"
- * type=department → AdminOrDeptAllowed
- с кодом DeptPerm.CREATE_POST|publish_department_post
+ * type=department → staff/superuser, руководитель отдела,
+ активный участник отдела или любой сотрудник
+ с активной ролью в этом отделе
- update/partial_update/destroy:
* company → как для company-create
* department → как для department-create
@@ -102,6 +104,23 @@ class PostViewSet(viewsets.ModelViewSet):
return True # пропускаем к валидации сериализатора
return super().has_permission(request, view)
+ class DepartmentPostCreatePerm(IsAuthenticated):
+ """Упрощённое право на создание новости отдела."""
+
+ def has_permission(self, request, view) -> bool:
+ if not super().has_permission(request, view):
+ return False
+
+ dept_id = _to_id(
+ request.data.get("department")
+ or request.data.get("department_id")
+ )
+ if dept_id is None:
+ # Даём сериализатору вернуть 400 по отсутствующему отделу.
+ return True
+
+ return can_publish_department_posts(request.user, dept_id)
+
def get_permissions(self) -> List[Any]:
"""Возвращает DRF-пермишены под текущий action.
@@ -118,13 +137,9 @@ class PostViewSet(viewsets.ModelViewSet):
t = (self.request.data.get("type") or "").strip()
if t == TYPE_COMPANY:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
- # department → департаментное право на создание
- dept_code = getattr(
- DeptPerm, "CREATE_POST", "publish_department_post"
- )
- perm = self.PostDeptPerm()
- perm.required_code = DeptPerm.CREATE_POST
- return [IsAuthenticated(), perm]
+ if t == TYPE_DEPARTMENT:
+ return [self.DepartmentPostCreatePerm()]
+ return [IsAuthenticated()]
# --- UPDATE/PATCH/DELETE: определяем тип поста из БД,
# но без get_object() ---
diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py
index d1ac7170..6d76678e 100644
--- a/backend/api/v1/permissions.py
+++ b/backend/api/v1/permissions.py
@@ -5,11 +5,7 @@ from typing import Any, Dict, Iterable, Optional, Tuple
from django.contrib.auth.models import AbstractBaseUser
from employees.models import Department, EmployeeDepartment, RoleAssignment
-from rest_framework.permissions import (
- SAFE_METHODS,
- BasePermission,
- DjangoModelPermissions,
-)
+from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions
from rest_framework.request import Request
MANAGE_PERM = "manage_department"
@@ -141,6 +137,39 @@ def has_dept_perm(
return link.role.scoped_permissions.filter(code=code).exists()
+def can_publish_department_posts(
+ user: AbstractBaseUser, department_id: int
+) -> bool:
+ """Возвращает True, если пользователь может публиковать новости отдела.
+
+ Упрощённый продуктовый контракт:
+ - staff/superuser → всегда True
+ - руководитель отдела → True
+ - любой активный физический участник отдела → True
+ - любой сотрудник с активной ролью в отделе → True
+ """
+ if not getattr(user, "is_authenticated", False):
+ return False
+ if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
+ return True
+
+ if Department.objects.filter(id=department_id, head_id=user.id).exists():
+ return True
+
+ if EmployeeDepartment.objects.filter(
+ employee_id=user.id,
+ department_id=department_id,
+ is_active=True,
+ ).exists():
+ return True
+
+ return RoleAssignment.objects.filter(
+ employee_id=user.id,
+ role__department_id=department_id,
+ is_active=True,
+ ).exists()
+
+
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ
по праву на конкретный отдел.
diff --git a/backend/tests/api/v1/employees/test_department_user_perms.py b/backend/tests/api/v1/employees/test_department_user_perms.py
index d4a0306b..c2fdac0b 100644
--- a/backend/tests/api/v1/employees/test_department_user_perms.py
+++ b/backend/tests/api/v1/employees/test_department_user_perms.py
@@ -9,6 +9,7 @@ from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
+ EmployeeDepartment,
RoleAssignment,
)
from tests.conftest import _unique_phone
@@ -78,3 +79,40 @@ def test_department_user_perms_resolves_role_based_feed_rights(api_client: APICl
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["can_publish_posts"] is True
assert resp.json()["can_manage_feed"] is False
+
+
+@pytest.mark.django_db
+def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient):
+ employee = make_user()
+ dept = Department.objects.create(name="Склад")
+ EmployeeDepartment.objects.create(
+ employee=employee,
+ department=dept,
+ 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
+
+
+@pytest.mark.django_db
+def test_department_user_perms_allows_role_only_to_publish_without_scoped_perm(
+ api_client: APIClient,
+):
+ employee = make_user()
+ dept = Department.objects.create(name="Логистика")
+ role = DepartmentRole.objects.create(department=dept, name="Наблюдатель")
+ 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
index 5a6611c2..34010492 100644
--- a/backend/tests/api/v1/feed/test_posts.py
+++ b/backend/tests/api/v1/feed/test_posts.py
@@ -173,6 +173,30 @@ def test_department_post_requires_publish_permission(
assert response.status_code == status.HTTP_403_FORBIDDEN
+def test_active_department_member_can_create_department_post(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ user = make_user("member-publisher@example.com")
+ department = department_factory(name="Operations")
+ EmployeeDepartment.objects.create(
+ employee=user,
+ department=department,
+ is_active=True,
+ )
+
+ 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_role_assignment_can_create_department_post(
auth_client_factory,
make_user,
@@ -193,6 +217,34 @@ def test_role_assignment_can_create_department_post(
assert Post.objects.filter(id=response.data["id"], department=department).exists()
+def test_any_role_assignment_can_create_department_post(
+ auth_client_factory,
+ make_user,
+ department_factory,
+):
+ user = make_user("roleonly-publisher@example.com")
+ department = department_factory(name="Support")
+ role = DepartmentRole.objects.create(
+ department=department,
+ name="Observer",
+ )
+ RoleAssignment.objects.create(
+ employee=user,
+ role=role,
+ is_active=True,
+ )
+
+ 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,
diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx
index e37aedbf..ae57f351 100644
--- a/frontend/src/app/departments/[id]/page.tsx
+++ b/frontend/src/app/departments/[id]/page.tsx
@@ -11,7 +11,7 @@ import {
ChevronRight,
Crown,
Link2,
- MessageCircle,
+ MessageSquare,
PencilLine,
Plus,
Search,
@@ -996,7 +996,7 @@ export default function DepartmentDetailPage() {
disabled={openingDepartmentChat}
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
-