731 lines
28 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 transaction, IntegrityError
from django.db.models import Count, Exists, Max, Min, OuterRef, Sum, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
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 (
Project,
ProjectMembership,
ProjectExternalBinding,
ProjectEvent,
TimeAllocation,
DailyAllocation,
AllocationReminder,
ProjectBoardLink,
)
from projects.permissions import visible_projects, can_create, can_manage
from projects.services.time_tracking import day_data, save_day, claim_reminder
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 (
ProjectSerializer,
MembershipSerializer,
StageSerializer,
BindingSerializer,
ResourceSerializer,
DaySerializer,
ExternalProjectImportSerializer,
)
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):
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.payroll.access import can_access_payroll_management
from finance.analytics.labor import labor_summary
from finance.analytics.projects import payment_queryset
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]
def get(self, request):
day = parse_date(
request.query_params.get("date", timezone.localdate().isoformat())
)
return Response(day_data(request.user, day))
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]
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]
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})