procurement: add item comments

This commit is contained in:
lizerk 2026-05-22 14:57:45 +03:00
parent 88b0755e82
commit 9cd21a1b1e
4 changed files with 411 additions and 5 deletions

View File

@ -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",
]

View File

@ -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<comment_id>[^/.]+)",
)
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):
"""Создать оборудование из позиции закупки.

View File

@ -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 (

View File

@ -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
# ==============================================================================
# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК
# ==============================================================================