Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

760 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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})