from datetime import timedelta from decimal import Decimal from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import IntegrityError, transaction from django.db.models import Count, Exists, Max, Min, OuterRef, Q, Sum from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import permissions, serializers, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response from rest_framework.views import APIView from commerce.models import ( ExternalProject, ProjectPayment, Sale, SourceRecord, SourceSystem, ) from projects.models import ( AllocationReminder, DailyAllocation, Project, ProjectBoardLink, ProjectEvent, ProjectExternalBinding, ProjectMembership, TimeAllocation, ) from projects.permissions import can_create, can_manage, visible_projects from projects.services.time_tracking import claim_reminder, day_data, save_day from tasks.access import task_board_access_q from tasks.application import link_task_object, unlink_task_object from tasks.models import Task, TaskBoard, TaskLinkedObject, TaskLinkedObjectKind from tasks.presentation import linked_task_badges_for_object from .serializers import ( BindingSerializer, CalendarDayResponseSerializer, DayResponseSerializer, DaySerializer, ExternalProjectImportSerializer, MembershipSerializer, ProjectSerializer, ReminderRequestSerializer, ReminderResponseSerializer, ResourceSerializer, StageSerializer, ) class PrivateResponseMixin: def finalize_response(self, request, response, *args, **kwargs): response = super().finalize_response(request, response, *args, **kwargs) response["Cache-Control"] = "private, no-store" return response class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = ProjectSerializer http_method_names = ["get", "post", "patch", "delete", "head", "options"] def get_queryset(self): if getattr(self, "swagger_fake_view", False): return Project.objects.none() qs = visible_projects(self.request.user).select_related("manager") if self.request.query_params.get("mine") == "true": qs = qs.filter( Q(manager=self.request.user) | Q(memberships__employee=self.request.user) ).distinct() search = self.request.query_params.get("search", "").strip() return ( qs.filter(Q(name__icontains=search) | Q(customer__icontains=search)) if search else qs ) def perform_create(self, serializer): if not can_create(self.request.user): raise PermissionDenied( "Создание проектов доступно администраторам и пользователям с правом создания проектов." ) with transaction.atomic(): p = serializer.save(created_by=self.request.user) ProjectMembership.objects.create( project=p, employee=p.manager, role="Руководитель", starts_on=timezone.localdate(), ) ProjectEvent.objects.create( project=p, actor=self.request.user, action="Проект создан" ) def require_manager(self): p = self.get_object() if not can_manage(self.request.user, p): raise PermissionDenied("Управление доступно руководителю проекта.") return p def perform_update(self, serializer): p = self.require_manager() with transaction.atomic(): serializer.save() ProjectEvent.objects.create( project=p, actor=self.request.user, action="Карточка изменена", details={k: str(v) for k, v in serializer.validated_data.items()}, ) def destroy(self, request, *args, **kwargs): raise ValidationError("Проекты сохраняют историю. Используйте статус «Архив».") @action(detail=False, methods=["get"]) def options(self, request): # Employee directory visibility follows the portal's authenticated directory. employees = ( get_user_model() .objects.filter(is_active=True) .order_by("last_name", "first_name") ) manageable = visible_projects(request.user) if not (request.user.is_staff or request.user.is_superuser): manageable = manageable.filter(manager=request.user) return Response( { "can_create": can_create(request.user), "employees": [{"id": u.pk, "name": str(u)} for u in employees], "manageable_projects": list( manageable.order_by("name").values("id", "name")[:500] ), } ) @action(detail=False, methods=["get"], url_path="external-candidates") def external_candidates(self, request): page = serializers.IntegerField(min_value=1).run_validation( request.query_params.get("page", 1) ) page_size = 24 search = request.query_params.get("q", "").strip() binding = ProjectExternalBinding.objects.filter( source_id=OuterRef("source_id"), object_type="project", external_id=OuterRef("external_id"), ) rows = ( ExternalProject.objects.filter(archived=False) .annotate(is_bound=Exists(binding)) .filter(is_bound=False) .select_related("source") .order_by("name", "pk") ) if search: rows = rows.filter(name__icontains=search) count = rows.count() start = (page - 1) * page_size return Response( { "count": count, "next": page + 1 if start + page_size < count else None, "previous": page - 1 if page > 1 else None, "results": [ { "source": row.source_id, "source_name": row.source.name, "external_id": row.external_id, "external_name": row.name, "bound": False, } for row in rows[start : start + page_size] ], } ) @action(detail=False, methods=["get"], url_path="external-project") def external_project(self, request): source_id = serializers.IntegerField(min_value=1).run_validation( request.query_params.get("source") ) external_id = serializers.CharField(max_length=255).run_validation( request.query_params.get("external_id") ) external = get_object_or_404( ExternalProject.objects.select_related("source"), source_id=source_id, external_id=external_id, archived=False, ) bound = ProjectExternalBinding.objects.filter( source_id=external.source_id, object_type="project", external_id=external.external_id, ).exists() source_record = ( SourceRecord.objects.filter( source_id=external.source_id, entity_type="project", external_id=external.external_id, ) .only("source_updated_at") .first() ) payment_rows = list( ProjectPayment.objects.filter( project=external, applicable=True, missing_from_source=False, ) .values("currency") .annotate(count=Count("pk"), amount=Sum("amount")) .order_by("currency") ) payment_period = ProjectPayment.objects.filter( project=external, applicable=True, missing_from_source=False, ).aggregate(first=Min("occurred_at"), last=Max("occurred_at")) sales = Sale.objects.filter(project=external, counts_as_sale=True) sales_period = sales.aggregate( first=Min("occurred_at"), last=Max("occurred_at") ) return Response( { "source": external.source_id, "source_name": external.source.name, "external_id": external.external_id, "external_name": external.name, "code": external.code, "description": external.description, "synced_at": external.synced_at, "source_updated_at": ( source_record.source_updated_at if source_record else None ), "bound": bound, "payment_count": sum(row["count"] for row in payment_rows), "payments": payment_rows, "payment_first_at": payment_period["first"], "payment_last_at": payment_period["last"], "sale_count": sales.count(), "sale_first_at": sales_period["first"], "sale_last_at": sales_period["last"], } ) @action(detail=False, methods=["post"], url_path="from-external") def from_external(self, request): if not can_create(request.user): raise PermissionDenied( "Создание проектов доступно администраторам и пользователям с правом создания проектов." ) payload = ExternalProjectImportSerializer(data=request.data) payload.is_valid(raise_exception=True) values = payload.validated_data with transaction.atomic(): external = get_object_or_404( ExternalProject.objects.select_for_update(), source=values["source"], external_id=values["external_id"], archived=False, ) if ProjectExternalBinding.objects.filter( source=external.source, object_type="project", external_id=external.external_id, ).exists(): raise ValidationError( "Этот внешний проект уже привязан к локальному проекту." ) project = Project.objects.create( name=values["name"], description=values["description"], customer=values["customer"], manager=values["manager"], status=values["status"], starts_on=values.get("starts_on"), ends_on=values.get("ends_on"), created_by=request.user, ) ProjectMembership.objects.create( project=project, employee=project.manager, role="Руководитель", starts_on=timezone.localdate(), ) try: with transaction.atomic(): ProjectExternalBinding.objects.create( project=project, source=external.source, object_type="project", external_id=external.external_id, external_name=external.name, ) except IntegrityError as exc: raise ValidationError( "Этот внешний проект уже привязан к локальному проекту." ) from exc ProjectEvent.objects.create( project=project, actor=request.user, action="Проект создан из внешнего проекта", details={ "source": external.source.name, "external_id": external.external_id, "external_name": external.name, }, ) return Response( ProjectSerializer(project, context={"request": request}).data, status=201, ) @action(detail=True, methods=["get", "post", "delete"]) def members(self, request, pk=None): p = self.get_object() if request.method == "GET": return Response( MembershipSerializer( p.memberships.select_related("employee"), many=True ).data ) self.require_manager() with transaction.atomic(): if request.method == "DELETE": member = get_object_or_404( p.memberships, pk=parse_id(request.data.get("id")) ) name = str(member.employee) member.delete() ProjectEvent.objects.create( project=p, actor=request.user, action="Участник удалён", details={"employee": name}, ) else: existing = p.memberships.filter( employee_id=parse_id(request.data.get("employee")) ).first() s = MembershipSerializer(existing, data=request.data) s.is_valid(raise_exception=True) s.save(project=p) ProjectEvent.objects.create( project=p, actor=request.user, action="Участие обновлено", details={k: str(v) for k, v in s.validated_data.items()}, ) return Response( MembershipSerializer( p.memberships.select_related("employee"), many=True ).data ) @action(detail=True, methods=["get", "post", "delete"]) def stages(self, request, pk=None): return self.collection(request, "stages", StageSerializer) @action(detail=True, methods=["get", "post", "delete"]) def resources(self, request, pk=None): return self.collection(request, "resources", ResourceSerializer) @action(detail=True, methods=["get", "post", "delete"], url_path="task-links") def task_links(self, request, pk=None): p = self.get_object() if request.method != "GET": p = self.require_manager() content_type = ContentType.objects.get_for_model(Project) if request.method == "POST": task_id = parse_id(request.data.get("task_id")) task = get_object_or_404( Task.objects.select_related("board", "column").filter( board__in=TaskBoard.objects.filter( task_board_access_q(request.user) ) ), pk=task_id, ) _, created = link_task_object( task=task, actor=request.user, kind=TaskLinkedObjectKind.PROJECT, content_type=content_type, object_id=p.pk, object_label=p.name, object_type="Проект", realtime_type="project", ) if created: ProjectEvent.objects.create( project=p, actor=request.user, action="Задача связана", details={"task_id": str(task.pk), "task": task.title}, ) elif request.method == "DELETE": link = get_object_or_404( TaskLinkedObject, pk=parse_id(request.data.get("id")), kind=TaskLinkedObjectKind.PROJECT, content_type=content_type, object_id=p.pk, ) task_title = link.task.title unlink_task_object( task=link.task, actor=request.user, kind=TaskLinkedObjectKind.PROJECT, link_id=link.pk, object_type="Проект", realtime_type="project", ) ProjectEvent.objects.create( project=p, actor=request.user, action="Связь с задачей удалена", details={"task": task_title}, ) return Response( linked_task_badges_for_object( obj=p, kind=TaskLinkedObjectKind.PROJECT, user=request.user, include_task_card_fields=True, ) ) @action(detail=True, methods=["get", "post", "delete"], url_path="board-links") def board_links(self, request, pk=None): p = self.get_object() if request.method != "GET": p = self.require_manager() accessible = ( TaskBoard.objects.filter(is_archived=False) .filter(task_board_access_q(request.user)) .distinct() ) if request.method == "POST": board = get_object_or_404( accessible, pk=parse_id(request.data.get("board_id")) ) _, created = ProjectBoardLink.objects.get_or_create( project=p, board=board, defaults={"created_by": request.user}, ) if created: ProjectEvent.objects.create( project=p, actor=request.user, action="Доска связана", details={"board_id": str(board.pk), "board": board.name}, ) elif request.method == "DELETE": link = get_object_or_404( p.board_links.select_related("board"), pk=parse_id(request.data.get("id")), ) board_name = link.board.name link.delete() ProjectEvent.objects.create( project=p, actor=request.user, action="Связь с доской удалена", details={"board": board_name}, ) links = p.board_links.filter(board__in=accessible).select_related("board") return Response( [ { "id": link.pk, "board_id": link.board_id, "name": link.board.name, "url": f"/tasks?board={link.board_id}", } for link in links ] ) def collection(self, request, relation, serializer_class): p = self.get_object() rows = getattr(p, relation) if request.method != "GET": self.require_manager() with transaction.atomic(): obj = ( get_object_or_404(rows, pk=parse_id(request.data["id"])) if request.data.get("id") else None ) if request.method == "DELETE": if obj is None: raise ValidationError("Не указана запись.") obj.delete() else: s = serializer_class(obj, data=request.data) s.is_valid(raise_exception=True) s.save(project=p) ProjectEvent.objects.create( project=p, actor=request.user, action="Этапы обновлены" if relation == "stages" else "Связанные материалы обновлены", ) return Response(serializer_class(rows.all(), many=True).data) @action(detail=True, methods=["get", "post", "delete"]) def bindings(self, request, pk=None): p = self.require_manager() if request.method != "GET": with transaction.atomic(): if request.method == "DELETE": b = get_object_or_404( p.bindings, pk=parse_id(request.data.get("id")) ) details = {"source": b.source_id, "external_id": b.external_id} b.delete() else: s = BindingSerializer(data=request.data) s.is_valid(raise_exception=True) details = {k: str(v) for k, v in s.validated_data.items()} try: with transaction.atomic(): s.save(project=p) except IntegrityError: raise ValidationError( "Этот внешний объект уже привязан к локальному проекту." ) ProjectEvent.objects.create( project=p, actor=request.user, action="Привязки обновлены", details=details, ) return Response( BindingSerializer(p.bindings.select_related("source"), many=True).data ) @action(detail=True, methods=["get"], url_path="external-options") def external_options(self, request, pk=None): self.require_manager() search = request.query_params.get("q", "") bound = ProjectExternalBinding.objects.filter( object_type="project" ).values_list("source_id", "external_id") identities = set(bound) rows = ( ExternalProject.objects.filter(name__icontains=search) .select_related("source") .order_by("name")[:200] ) return Response( { "sources": list(SourceSystem.objects.values("id", "name")), "external_projects": [ { "source": r.source_id, "source_name": r.source.name, "external_id": r.external_id, "external_name": r.name, "bound": (r.source_id, r.external_id) in identities, } for r in rows ], } ) @action(detail=True, methods=["get"]) def history(self, request, pk=None): p = self.require_manager() return Response( [ { "id": e.pk, "actor": str(e.actor), "action": e.action, "details": e.details, "created_at": e.created_at, } for e in p.events.select_related("actor")[:100] ] ) @action(detail=True, methods=["get"], url_path="time-report") def time_report(self, request, pk=None): p = self.get_object() today = timezone.localdate() start = parse_date( request.query_params.get("from", today.replace(day=1).isoformat()) ) end = parse_date(request.query_params.get("to", today.isoformat())) if end.year >= 9999 or end < start or (end - start).days > 366: raise ValidationError("Выберите период до 367 дней.") rows = TimeAllocation.objects.filter( project=p, day__date__range=[start, end] ).select_related("day__employee") manager = can_manage(request.user, p) if not manager: rows = rows.filter(day__employee=request.user) results = [] total = Decimal(0) unknown = drafts = 0 for a in rows.order_by("-day__date", "day__employee_id"): hours = a.day.hours * a.percent / 100 if a.day.hours is not None else None if a.day.status == "confirmed": if hours is None: unknown += 1 else: total += hours else: drafts += 1 results.append( { "date": a.day.date, "employee": str(a.day.employee), "percent": a.percent, "hours": hours, "status": a.day.status, "hours_source": a.day.hours_source, } ) return Response( { "from": start, "to": end, "scope": "team" if manager else "self", "confirmed_hours": total, "unknown_days": unknown, "draft_days": drafts, "rows": results, } ) @action(detail=True, methods=["get"]) def finances(self, request, pk=None): from finance.analytics.labor import labor_summary from finance.analytics.projects import payment_queryset from finance.payroll.access import can_access_payroll_management p = self.get_object() if not can_access_payroll_management(request.user): raise PermissionDenied("Нет доступа к финансовой аналитике.") today = timezone.localdate() start = parse_date( request.query_params.get("from", today.replace(month=1, day=1).isoformat()) ) end = parse_date(request.query_params.get("to", today.isoformat())) if end.year >= 9999 or end < start or (end - start).days > 366: raise ValidationError("Выберите период до 367 дней.") currencies = request.query_params.get("currency", "RUB") currencies = serializers.RegexField(r"^[A-Z]{3}$").run_validation( currencies.upper() ) condition = Q(pk__in=[]) for b in p.bindings.filter(object_type="project"): condition |= Q(source_id=b.source_id, external_id=b.external_id) external = ExternalProject.objects.filter(condition) payments = payment_queryset(start, end, currencies.upper()).filter( project__in=external ) allocations = TimeAllocation.objects.filter( project=p, day__date__range=(start, end), day__status="confirmed", ).select_related("day__employee") labor = labor_summary(allocations, currencies.upper()) return Response( { "from": start, "to": end, "currency": currencies.upper(), "external_project_count": external.count(), "payment_count": payments.count(), "paid": payments.aggregate(total=Sum("amount"))["total"], "categories": list( payments.values("expense_category") .annotate(paid=Sum("amount")) .order_by("-paid") ), **labor, } ) def parse_id(value): return serializers.IntegerField(min_value=1).run_validation(value) def parse_date(value): s = serializers.DateField() return s.run_validation(value) class OwnDayView(PrivateResponseMixin, APIView): permission_classes = [permissions.IsAuthenticated] @extend_schema( parameters=[ OpenApiParameter("date", type={"type": "string", "format": "date"}) ], responses=DayResponseSerializer, ) def get(self, request): day = parse_date( request.query_params.get("date", timezone.localdate().isoformat()) ) return Response(day_data(request.user, day)) @extend_schema(request=DaySerializer, responses=DayResponseSerializer) def post(self, request): s = DaySerializer(data=request.data) s.is_valid(raise_exception=True) return Response(save_day(request.user, s.validated_data)) class OwnCalendarView(PrivateResponseMixin, APIView): permission_classes = [permissions.IsAuthenticated] @extend_schema(responses=CalendarDayResponseSerializer(many=True)) def get(self, request): rows = DailyAllocation.objects.filter( employee=request.user, date__gte=timezone.localdate() - timedelta(days=90) ) return Response( list(rows.values("date", "status", "hours", "hours_source", "version")) ) class ReminderView(PrivateResponseMixin, APIView): permission_classes = [permissions.IsAuthenticated] @extend_schema( request=ReminderRequestSerializer, responses=ReminderResponseSerializer ) def post(self, request): if serializers.BooleanField().run_validation(request.data.get("snooze", False)): return self.snooze(request) return Response({"date": claim_reminder(request.user)}) @transaction.atomic def snooze(self, request): get_user_model().objects.select_for_update().get(pk=request.user.pk) AllocationReminder.objects.update_or_create( employee=request.user, defaults={ "last_claim_date": None, "snoozed_until": timezone.now() + timedelta(hours=2), }, ) return Response({"date": None})