procurement: add item comments
This commit is contained in:
parent
88b0755e82
commit
9cd21a1b1e
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
"""Создать оборудование из позиции закупки.
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК
|
||||
# ==============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user