From 9cd21a1b1eb061d1ed9944a9b0e3d22b2362a5db Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 22 May 2026 14:57:45 +0300 Subject: [PATCH] procurement: add item comments --- backend/api/v1/procurement/serializers.py | 13 ++ backend/api/v1/procurement/views.py | 165 ++++++++++++++++++ backend/employees/utils.py | 82 ++++++++- .../procurement/test_procurement_requests.py | 156 +++++++++++++++++ 4 files changed, 411 insertions(+), 5 deletions(-) diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index 1ad264be..ed4f1e77 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -32,6 +32,17 @@ class ProcurementItemSerializer(serializers.ModelSerializer): execution_status_display = serializers.CharField( source="get_execution_status_display", read_only=True ) + comments_count = serializers.SerializerMethodField() + + @extend_schema_field(serializers.IntegerField()) + def get_comments_count(self, obj): + annotated_count = getattr(obj, "comments_count", None) + if annotated_count is not None: + return annotated_count + + from communications.comments_helpers import get_comment_count + + return get_comment_count(obj) class Meta: model = ProcurementItem @@ -51,6 +62,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "execution_status", "execution_status_display", "executor_comment", + "comments_count", "equipment", "created_at", ] @@ -59,6 +71,7 @@ class ProcurementItemSerializer(serializers.ModelSerializer): "created_at", "total_price", "execution_status_display", + "comments_count", ] diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 96e31cb8..fd303548 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -759,6 +759,36 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): filterset_fields = ["request"] search_fields = ["name", "description"] + def get_queryset(self): + """Annotate item comments count.""" + from django.contrib.contenttypes.models import ContentType + from django.db.models import Count, IntegerField, OuterRef, Subquery, Value + from django.db.models.functions import Coalesce + from communications.models import Chat + + ct = ContentType.objects.get_for_model(ProcurementItem) + comments_sub = ( + Chat.objects.filter( + type="comments", + context_content_type=ct, + context_object_id=OuterRef("pk"), + ) + .annotate( + msg_count=Count( + "messages", filter=Q(messages__is_deleted=False) + ) + ) + .values("msg_count")[:1] + ) + + return super().get_queryset().annotate( + comments_count=Coalesce( + Subquery(comments_sub, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ) + ) + def _is_processing_department_member(self, user, procurement_request): if not procurement_request.processing_department_id: return False @@ -784,6 +814,34 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): procurement_request, ) + def _can_access_item_comments(self, user, procurement_request): + if not user or not user.is_authenticated: + return False + if user.is_superuser or user.is_staff: + return True + if ( + user.has_perm("procurement.view_procurementrequest") + or user.has_perm("procurement.change_procurementrequest") + or user.has_perm("procurement.execute_procurement") + ): + return True + if procurement_request.requestor_id == user.id: + return True + if procurement_request.executor_id == user.id: + return True + if procurement_request.department.head_id == user.id: + return True + if EmployeeDepartment.objects.filter( + employee_id=user.id, + department_id=procurement_request.department_id, + is_active=True, + ).exists(): + return True + return self._is_processing_department_member( + user, + procurement_request, + ) + def get_permissions(self): """Только создатель заявки может редактировать позиции.""" if self.action in [ @@ -793,6 +851,8 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): "destroy", "create_equipment", "link_equipment", + "comments", + "delete_comment", ]: permission_classes = [permissions.IsAuthenticated] else: @@ -891,6 +951,111 @@ class ProcurementItemViewSet(viewsets.ModelViewSet): self._check_item_edit_permission(instance) instance.delete() + @action(detail=True, methods=["get", "post"]) + def comments(self, request, pk=None): + """Список/создание комментариев для позиции заявки.""" + from api.v1.employees.serializers.employee import ( + EmployeeBriefSerializer, + ) + + item = self.get_object() + if not self._can_access_item_comments(request.user, item.request): + return Response( + {"detail": "Нет прав на комментарии этой позиции"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if request.method in ("GET", "HEAD"): + messages = comments_helpers.get_comments(item) + comments_data = [] + for msg in messages: + author_ser = EmployeeBriefSerializer(msg.author) + comments_data.append( + { + "id": msg.id, + "item": item.id, + "request": item.request_id, + "author": author_ser.data, + "text": msg.content, + "created_at": msg.created_at, + } + ) + return Response(comments_data) + + text = request.data.get("text", "").strip() + if not text: + return Response( + {"text": ["Это поле не может быть пустым."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + message = comments_helpers.create_comment( + obj=item, + author=request.user, + content=text, + ) + author_ser = EmployeeBriefSerializer(message.author) + return Response( + { + "id": message.id, + "item": item.id, + "request": item.request_id, + "author": author_ser.data, + "text": message.content, + "created_at": message.created_at, + }, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="comment_id", + type=int, + location=OpenApiParameter.PATH, + description="ID комментария в чате комментариев позиции.", + ) + ] + ) + @action( + detail=True, + methods=["delete"], + url_path="comments/(?P[^/.]+)", + ) + def delete_comment(self, request, pk=None, comment_id=None): + """Удаление комментария к позиции заявки.""" + item = self.get_object() + if not self._can_access_item_comments(request.user, item.request): + return Response( + {"detail": "Нет прав на комментарии этой позиции"}, + status=status.HTTP_403_FORBIDDEN, + ) + + chat = comments_helpers.get_or_create_comments_chat(item) + if isinstance(chat, tuple): + chat = chat[0] + + try: + message = Message.objects.get(id=comment_id, chat=chat) + except Message.DoesNotExist: + return Response( + {"detail": "Комментарий не найден"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not (request.user.is_staff or message.author == request.user): + return Response( + {"detail": "Нет прав на удаление"}, + status=status.HTTP_403_FORBIDDEN, + ) + + comments_helpers.delete_comment( + message=message, + deleted_by=request.user, + soft_delete=True, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["post"]) def create_equipment(self, request, pk=None): """Создать оборудование из позиции закупки. diff --git a/backend/employees/utils.py b/backend/employees/utils.py index 34225fd0..f27ffe96 100644 --- a/backend/employees/utils.py +++ b/backend/employees/utils.py @@ -359,6 +359,72 @@ def resolve_chat_participants_for_department(context_object, **kwargs): ).distinct() +def resolve_chat_participants_for_procurement_request( + context_object, + **kwargs, +): + """ + EUSRR callback для участников комментариев заявки на закупку. + + Участники: заявитель, исполнитель, согласующие, отдел заявителя, + отдел-исполнитель и администраторы. + """ + from django.db.models import Q + from procurement.models import ProcurementRequest + + if not isinstance(context_object, ProcurementRequest): + return Employee.objects.none() + + procurement_request = context_object + department_ids = [procurement_request.department_id] + if procurement_request.processing_department_id: + department_ids.append(procurement_request.processing_department_id) + + employee_ids = EmployeeDepartment.objects.filter( + department_id__in=department_ids, + is_active=True, + ).values_list("employee_id", flat=True) + + approval_user_ids = procurement_request.approvals.values_list( + "approver_id", + flat=True, + ) + + query = ( + Q(id=procurement_request.requestor_id) + | Q(id=procurement_request.executor_id) + | Q(id__in=employee_ids) + | Q(id__in=approval_user_ids) + | Q(is_staff=True) + | Q(is_superuser=True) + ) + + if procurement_request.department.head_id: + query |= Q(id=procurement_request.department.head_id) + if ( + procurement_request.processing_department_id + and procurement_request.processing_department.head_id + ): + query |= Q(id=procurement_request.processing_department.head_id) + + return Employee.objects.filter(query, is_active=True).distinct() + + +def resolve_chat_participants_for_procurement_item(context_object, **kwargs): + """ + EUSRR callback для участников комментариев позиции заявки на закупку. + """ + from procurement.models import ProcurementItem + + if not isinstance(context_object, ProcurementItem): + return Employee.objects.none() + + return resolve_chat_participants_for_procurement_request( + context_object.request, + **kwargs, + ) + + def resolve_chat_participants(chat): """ Главная функция для разрешения участников чата в EUSRR. @@ -393,11 +459,17 @@ def resolve_chat_participants(chat): chat.context_object, chat=chat, type=chat.type ) - # Можно добавить другие типы контекстов: - # - Project - # - Team - # - Event - # и т.д. + from procurement.models import ProcurementItem, ProcurementRequest + + if isinstance(chat.context_object, ProcurementRequest): + return resolve_chat_participants_for_procurement_request( + chat.context_object, chat=chat, type=chat.type + ) + + if isinstance(chat.context_object, ProcurementItem): + return resolve_chat_participants_for_procurement_item( + chat.context_object, chat=chat, type=chat.type + ) # 5. Legacy: department field (DEPRECATED, для обратной совместимости) if ( diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index acc27f7d..13430158 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -494,6 +494,162 @@ class TestProcurementRequestComments: assert after_item['comments_count'] == 0 +class TestProcurementItemComments: + """Комментарии к позиции заявки на закупку.""" + + def test_item_comment_lifecycle_updates_comments_count( + self, api_client, user, procurement_item + ): + api_client.force_authenticate(user=user) + + list_url = reverse('api:v1:procurement:procurementitem-list') + comments_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': procurement_item.id} + ) + + before = api_client.get(list_url, {'request': procurement_item.request_id}) + assert before.status_code == status.HTTP_200_OK + before_item = next( + item for item in before.data['results'] + if item['id'] == procurement_item.id + ) + assert before_item['comments_count'] == 0 + + create_response = api_client.post( + comments_url, + {'text': 'Комментарий по конкретной позиции'}, + format='json', + ) + assert create_response.status_code == status.HTTP_201_CREATED + assert create_response.data['item'] == procurement_item.id + assert create_response.data['request'] == procurement_item.request_id + comment_id = create_response.data['id'] + + comments_response = api_client.get(comments_url) + assert comments_response.status_code == status.HTTP_200_OK + assert len(comments_response.data) == 1 + assert ( + comments_response.data[0]['text'] + == 'Комментарий по конкретной позиции' + ) + + middle = api_client.get(list_url, {'request': procurement_item.request_id}) + middle_item = next( + item for item in middle.data['results'] + if item['id'] == procurement_item.id + ) + assert middle_item['comments_count'] == 1 + + request_detail_url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_item.request_id} + ) + request_detail = api_client.get(request_detail_url) + assert request_detail.status_code == status.HTTP_200_OK + detail_item = next( + item for item in request_detail.data['items'] + if item['id'] == procurement_item.id + ) + assert detail_item['comments_count'] == 1 + + delete_url = reverse( + 'api:v1:procurement:procurementitem-delete-comment', + kwargs={'pk': procurement_item.id, 'comment_id': comment_id} + ) + delete_response = api_client.delete(delete_url) + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + after_comments = api_client.get(comments_url) + assert after_comments.status_code == status.HTTP_200_OK + assert after_comments.data == [] + + after = api_client.get(list_url, {'request': procurement_item.request_id}) + after_item = next( + item for item in after.data['results'] + if item['id'] == procurement_item.id + ) + assert after_item['comments_count'] == 0 + + def test_outsider_cannot_comment_item( + self, api_client, procurement_item + ): + outsider = Employee.objects.create_user( + email="item-outsider@example.com", + password="testpass123", + phone_number="+79997777771", + first_name="Чужой", + last_name="Сотрудник", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + api_client.force_authenticate(user=outsider) + + comments_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': procurement_item.id} + ) + + get_response = api_client.get(comments_url) + assert get_response.status_code == status.HTTP_403_FORBIDDEN + + post_response = api_client.post( + comments_url, + {'text': 'Не должен пройти'}, + format='json', + ) + assert post_response.status_code == status.HTTP_403_FORBIDDEN + + def test_processing_department_member_can_comment_item( + self, api_client, user, department, procurement_item_factory + ): + supply_department = Department.objects.create( + name="Снабжение для комментариев", + description="Отдел снабжения", + ) + supply_user = Employee.objects.create_user( + email="item-supply@example.com", + password="testpass123", + phone_number="+79997777772", + first_name="Снабжение", + last_name="Комментарий", + is_active=True, + email_verified=True, + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=supply_user, + department=supply_department, + is_active=True, + ) + procurement_request = ProcurementRequest.objects.create( + title="Адресная заявка для комментариев", + description="Нужны расходники", + department=department, + processing_department=supply_department, + requestor=user, + status=ProcurementStatus.WAITING, + urgency=UrgencyLevel.MEDIUM, + ) + item = procurement_item_factory(request=procurement_request) + + api_client.force_authenticate(user=supply_user) + comments_url = reverse( + 'api:v1:procurement:procurementitem-comments', + kwargs={'pk': item.id} + ) + + response = api_client.post( + comments_url, + {'text': 'Уточнение от снабжения'}, + format='json', + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['item'] == item.id + + # ============================================================================== # ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК # ==============================================================================