409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""ViewSets для API модуля закупок."""
|
||
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
from django_filters.rest_framework import DjangoFilterBackend
|
||
from rest_framework import filters, permissions, viewsets
|
||
from rest_framework.decorators import action
|
||
from rest_framework.response import Response
|
||
|
||
from procurement.constants import (
|
||
ProcurementStatus,
|
||
)
|
||
from procurement.models import (
|
||
ProcurementItem,
|
||
ProcurementActivityAction,
|
||
ProcurementActivityObjectKind,
|
||
ProcurementRequest,
|
||
ProcurementRequestView,
|
||
)
|
||
from procurement.activity import create_procurement_activity
|
||
from procurement.notifications.config import NotificationVerbs
|
||
from procurement.services import ProcurementApprovalResolver
|
||
from ..permissions import (
|
||
CanApproveProcurementRequest,
|
||
CanManageProcurementRequest,
|
||
)
|
||
from ..serializers import (
|
||
ProcurementRequestCreateSerializer,
|
||
ProcurementRequestDetailSerializer,
|
||
ProcurementRequestActivitySerializer,
|
||
ProcurementRequestListSerializer,
|
||
)
|
||
from .mixins import RequestCommentsMixin, RequestExecutionMixin, RequestWorkflowMixin
|
||
|
||
|
||
class ProcurementRequestViewSet(
|
||
RequestWorkflowMixin,
|
||
RequestCommentsMixin,
|
||
RequestExecutionMixin,
|
||
viewsets.ModelViewSet,
|
||
):
|
||
"""ViewSet для заявок на закупку."""
|
||
|
||
queryset = ProcurementRequest.objects.select_related(
|
||
"department",
|
||
"processing_department",
|
||
"requestor",
|
||
"executor",
|
||
).prefetch_related(
|
||
"items__attachments__uploaded_by",
|
||
"approvals__approver",
|
||
)
|
||
filter_backends = [
|
||
DjangoFilterBackend,
|
||
filters.SearchFilter,
|
||
filters.OrderingFilter,
|
||
]
|
||
filterset_fields = {
|
||
"status": ["exact", "in"],
|
||
"urgency": ["exact"],
|
||
"department": ["exact"],
|
||
"processing_department": ["exact"],
|
||
"requestor": ["exact"],
|
||
"executor": ["exact"],
|
||
"fulfillment_status": ["exact"],
|
||
}
|
||
search_fields = ["title", "description"]
|
||
ordering_fields = ["created_at", "status"]
|
||
ordering = None
|
||
|
||
def get_serializer_class(self):
|
||
"""Выбрать сериализатор в зависимости от действия."""
|
||
if self.action == "create":
|
||
return ProcurementRequestCreateSerializer
|
||
elif self.action in [
|
||
"retrieve",
|
||
"submit",
|
||
"approve",
|
||
"reject",
|
||
"start_work",
|
||
"complete",
|
||
"cancel",
|
||
"mark_all_received",
|
||
"notify_arrival",
|
||
"set_viewed",
|
||
"approval_options",
|
||
"activity",
|
||
]:
|
||
return ProcurementRequestDetailSerializer
|
||
return ProcurementRequestListSerializer
|
||
|
||
@action(detail=True, methods=["get"], url_path="activity")
|
||
def activity(self, request, pk=None):
|
||
procurement_request = self.get_object()
|
||
queryset = procurement_request.activities.select_related("actor").order_by(
|
||
"-created_at",
|
||
"-id",
|
||
)
|
||
serializer = ProcurementRequestActivitySerializer(queryset, many=True)
|
||
return Response(serializer.data)
|
||
|
||
def perform_update(self, serializer):
|
||
tracked_fields = [
|
||
"title",
|
||
"description",
|
||
"department_id",
|
||
"processing_department_id",
|
||
"urgency",
|
||
"actual_cost",
|
||
]
|
||
before = {
|
||
field: getattr(serializer.instance, field) for field in tracked_fields
|
||
}
|
||
procurement_request = serializer.save()
|
||
changed_fields = [
|
||
field
|
||
for field in tracked_fields
|
||
if before[field] != getattr(procurement_request, field)
|
||
]
|
||
if changed_fields:
|
||
create_procurement_activity(
|
||
procurement_request,
|
||
self.request.user,
|
||
ProcurementActivityAction.UPDATED,
|
||
object_kind=ProcurementActivityObjectKind.REQUEST,
|
||
object_id=procurement_request.pk,
|
||
metadata={"fields": changed_fields},
|
||
)
|
||
|
||
def get_permissions(self):
|
||
"""Выбрать права доступа в зависимости от действия."""
|
||
if self.action in ["approve", "reject"]:
|
||
permission_classes = [CanApproveProcurementRequest]
|
||
elif self.action in [
|
||
"submit",
|
||
"set_viewed",
|
||
"create_options",
|
||
"approval_options",
|
||
]:
|
||
permission_classes = [permissions.IsAuthenticated]
|
||
else:
|
||
# Для create, update, delete и остальных действий
|
||
permission_classes = [CanManageProcurementRequest]
|
||
return [permission() for permission in permission_classes]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтровать заявки в зависимости от роли пользователя и scope."""
|
||
from datetime import timedelta
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.db.models import (
|
||
BooleanField,
|
||
CharField,
|
||
Count,
|
||
DateTimeField,
|
||
Exists,
|
||
IntegerField,
|
||
OuterRef,
|
||
Subquery,
|
||
Value,
|
||
)
|
||
from django.db.models.functions import Cast, Coalesce
|
||
from django.utils.dateparse import parse_date
|
||
from communications.models import Chat
|
||
from notifications.models import Notification
|
||
|
||
ct = ContentType.objects.get_for_model(ProcurementRequest)
|
||
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]
|
||
)
|
||
arrival_notice_sub = (
|
||
Notification.objects.filter(
|
||
verb=NotificationVerbs.ARRIVAL_NOTICE,
|
||
target_content_type=ct,
|
||
target_object_id=Cast(
|
||
OuterRef("pk"),
|
||
output_field=CharField(),
|
||
),
|
||
)
|
||
.order_by("-timestamp")
|
||
.values("timestamp")[:1]
|
||
)
|
||
last_defective_at_sub = (
|
||
ProcurementItem.objects.filter(
|
||
request_id=OuterRef("pk"),
|
||
defective_at__isnull=False,
|
||
)
|
||
.order_by("-defective_at")
|
||
.values("defective_at")[:1]
|
||
)
|
||
|
||
user = self.request.user
|
||
viewed_annotation = (
|
||
Exists(
|
||
ProcurementRequestView.objects.filter(
|
||
request_id=OuterRef("pk"),
|
||
user=user,
|
||
is_viewed=True,
|
||
)
|
||
)
|
||
if user and user.is_authenticated
|
||
else Value(False, output_field=BooleanField())
|
||
)
|
||
|
||
queryset = (
|
||
super()
|
||
.get_queryset()
|
||
.annotate(
|
||
comments_count=Coalesce(
|
||
Subquery(comments_sub, output_field=IntegerField()),
|
||
Value(0),
|
||
output_field=IntegerField(),
|
||
),
|
||
is_viewed_for_current_user=viewed_annotation,
|
||
last_arrival_notice_at=Subquery(
|
||
arrival_notice_sub,
|
||
output_field=DateTimeField(),
|
||
),
|
||
last_defective_at=Subquery(
|
||
last_defective_at_sub,
|
||
output_field=DateTimeField(),
|
||
),
|
||
)
|
||
.annotate(
|
||
default_sort_at=Coalesce(
|
||
"last_defective_at",
|
||
"created_at",
|
||
output_field=DateTimeField(),
|
||
),
|
||
)
|
||
)
|
||
scope = self.request.query_params.get("scope", None)
|
||
period = self.request.query_params.get("period", None)
|
||
date_from = self.request.query_params.get("date_from", None)
|
||
date_to = self.request.query_params.get("date_to", None)
|
||
participant_department_ids = (
|
||
ProcurementApprovalResolver.get_user_department_participant_ids(
|
||
user,
|
||
)
|
||
)
|
||
|
||
# Обработка scope параметра
|
||
if scope == "mine":
|
||
# Только мои заявки (где я автор)
|
||
queryset = queryset.filter(requestor=user)
|
||
elif scope == "department":
|
||
# Заявки отделов, где пользователь участник или role-only участник.
|
||
queryset = queryset.filter(department_id__in=participant_department_ids)
|
||
elif scope == "processing_department":
|
||
# Заявки, направленные в отделы, где пользователь участвует.
|
||
queryset = queryset.filter(
|
||
processing_department_id__in=participant_department_ids,
|
||
)
|
||
elif scope == "my_work":
|
||
# Заявки, которые я взял в работу (где я исполнитель)
|
||
queryset = queryset.filter(executor=user)
|
||
elif scope == "available":
|
||
queryset = queryset.filter(
|
||
status__in=ProcurementApprovalResolver.WORK_ASSIGNABLE_STATUSES,
|
||
).exclude(
|
||
executor=user,
|
||
)
|
||
if not (
|
||
user.is_superuser
|
||
or user.is_staff
|
||
or user.has_perm("procurement.change_procurementrequest")
|
||
or user.has_perm("procurement.execute_procurement")
|
||
):
|
||
queryset = queryset.filter(
|
||
Q(
|
||
processing_department_id__in=participant_department_ids,
|
||
)
|
||
| Q(
|
||
processing_department__isnull=True,
|
||
)
|
||
)
|
||
elif scope == "all":
|
||
# Все заявки - применяем стандартную логику прав
|
||
pass
|
||
else:
|
||
# Если scope не указан, применяем стандартную логику
|
||
# Админы и пользователи с модельными правами видят все
|
||
if user.is_superuser or user.is_staff:
|
||
pass
|
||
# Пользователи с любыми модельными правами на заявки видят все
|
||
elif (
|
||
user.has_perm("procurement.view_procurementrequest")
|
||
or user.has_perm("procurement.change_procurementrequest")
|
||
or user.has_perm("procurement.delete_procurementrequest")
|
||
or user.has_perm("procurement.execute_procurement")
|
||
):
|
||
pass
|
||
else:
|
||
# Показываем: свои заявки + заявки отдела + где я approver
|
||
queryset = queryset.filter(
|
||
Q(requestor=user)
|
||
| Q(department_id__in=participant_department_ids)
|
||
| Q(processing_department_id__in=participant_department_ids)
|
||
| Q(approvals__approver=user)
|
||
| Q(
|
||
status=ProcurementStatus.APPROVED,
|
||
processing_department__isnull=True,
|
||
)
|
||
)
|
||
|
||
# Фильтрация по периоду
|
||
if period:
|
||
now = timezone.now()
|
||
if period == "today":
|
||
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
queryset = queryset.filter(created_at__gte=start_date)
|
||
elif period == "week":
|
||
start_date = now - timedelta(days=7)
|
||
queryset = queryset.filter(created_at__gte=start_date)
|
||
elif period == "month":
|
||
start_date = now - timedelta(days=30)
|
||
queryset = queryset.filter(created_at__gte=start_date)
|
||
elif period == "quarter":
|
||
start_date = now - timedelta(days=90)
|
||
queryset = queryset.filter(created_at__gte=start_date)
|
||
|
||
if date_from:
|
||
parsed_date_from = parse_date(date_from)
|
||
if parsed_date_from:
|
||
queryset = queryset.filter(created_at__date__gte=parsed_date_from)
|
||
if date_to:
|
||
parsed_date_to = parse_date(date_to)
|
||
if parsed_date_to:
|
||
queryset = queryset.filter(created_at__date__lte=parsed_date_to)
|
||
|
||
if not self.request.query_params.get("ordering"):
|
||
queryset = queryset.order_by("-default_sort_at", "-id")
|
||
return queryset.distinct()
|
||
|
||
def _attach_linked_task_payloads(
|
||
self,
|
||
requests: list[ProcurementRequest],
|
||
) -> None:
|
||
"""Предзагрузить компактные бейджи задач для списка закупок."""
|
||
if not requests:
|
||
return
|
||
|
||
user = getattr(self.request, "user", None)
|
||
request_ids = [request_obj.id for request_obj in requests]
|
||
mapping = {request_id: [] for request_id in request_ids}
|
||
|
||
if user and user.is_authenticated:
|
||
from tasks.access import task_board_access_q
|
||
from tasks.models import (
|
||
TaskBoard,
|
||
TaskLinkedObject,
|
||
TaskLinkedObjectKind,
|
||
)
|
||
|
||
request_ct = ContentType.objects.get_for_model(ProcurementRequest)
|
||
accessible_boards = TaskBoard.objects.filter(
|
||
is_archived=False,
|
||
).filter(task_board_access_q(user))
|
||
|
||
links = (
|
||
TaskLinkedObject.objects.filter(
|
||
kind=TaskLinkedObjectKind.PROCUREMENT_REQUEST,
|
||
content_type=request_ct,
|
||
object_id__in=request_ids,
|
||
task__board__in=accessible_boards,
|
||
)
|
||
.select_related("task", "task__board", "task__column")
|
||
.order_by("object_id", "task__title", "task_id")
|
||
)
|
||
|
||
for link in links:
|
||
mapping.setdefault(link.object_id, []).append(
|
||
{
|
||
"link_id": link.id,
|
||
"id": link.task_id,
|
||
"title": link.task.title,
|
||
"board_id": link.task.board_id,
|
||
"board_name": link.task.board.name,
|
||
"column_id": link.task.column_id,
|
||
"column_name": link.task.column.name,
|
||
"column_color": link.task.column.color,
|
||
"priority": link.task.priority,
|
||
"priority_display": link.task.get_priority_display(),
|
||
}
|
||
)
|
||
|
||
for request_obj in requests:
|
||
request_obj._linked_task_payloads = mapping.get(request_obj.id, [])
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
queryset = self.filter_queryset(self.get_queryset())
|
||
|
||
page = self.paginate_queryset(queryset)
|
||
if page is not None:
|
||
self._attach_linked_task_payloads(list(page))
|
||
serializer = self.get_serializer(page, many=True)
|
||
return self.get_paginated_response(serializer.data)
|
||
|
||
requests = list(queryset)
|
||
self._attach_linked_task_payloads(requests)
|
||
serializer = self.get_serializer(requests, many=True)
|
||
return Response(serializer.data)
|