760 lines
29 KiB
Python
760 lines
29 KiB
Python
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})
|