251 lines
9.5 KiB
Python
251 lines
9.5 KiB
Python
# backend/api/v1/calendar/viewsets.py
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import datetime as dt
|
||
from typing import List
|
||
|
||
from django.core.cache import cache
|
||
from django.db.models import Q
|
||
from django.db.models.functions import ExtractDay, ExtractMonth
|
||
from django.http import Http404
|
||
from django.utils.http import http_date, parse_http_date_safe
|
||
from rest_framework.decorators import action
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.renderers import JSONRenderer
|
||
from rest_framework.response import Response
|
||
from rest_framework.viewsets import ViewSet
|
||
|
||
from calendar_app.models import CompanyEvent, DepartmentEvent, Recurrence
|
||
from .serializers import CompanyEventSerializer, DepartmentEventSerializer
|
||
from .utils import (
|
||
CACHE_TTL,
|
||
MAX_WINDOW_DAYS,
|
||
RESPONSE_HEADERS,
|
||
can_view_department,
|
||
etag_for,
|
||
get_range,
|
||
last_modified,
|
||
)
|
||
|
||
|
||
class CompanyEventsViewSet(ViewSet):
|
||
"""
|
||
GET /api/v1/calendar/company-events/?start=YYYY-MM-DD&end=YYYY-MM-DD
|
||
Разворачивает ежегодные события в вхождения внутри окна.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
def list(self, request):
|
||
start, end = get_range(request)
|
||
base_qs = CompanyEvent.objects.only(
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"date",
|
||
"recurrence",
|
||
"color",
|
||
"location",
|
||
"created_at",
|
||
)
|
||
lm = last_modified(base_qs, CompanyEvent)
|
||
|
||
# Без окна — просто всё (ограничим на всякий случай)
|
||
if not (start and end):
|
||
data = CompanyEventSerializer(
|
||
base_qs.order_by("date")[:1000], many=True
|
||
).data
|
||
resp = Response(data)
|
||
for k, v in RESPONSE_HEADERS.items():
|
||
resp.headers[k] = v
|
||
return resp
|
||
|
||
cache_key = (
|
||
f"cal:v1:company:{request.user.id}:{start.isoformat()}:{end.isoformat()}"
|
||
)
|
||
cached = cache.get(cache_key)
|
||
if cached:
|
||
# ETag
|
||
if request.META.get("HTTP_IF_NONE_MATCH") == cached["etag"]:
|
||
return Response(status=304)
|
||
# If-Modified-Since
|
||
ims = request.META.get("HTTP_IF_MODIFIED_SINCE")
|
||
if ims and lm:
|
||
ims_ts = parse_http_date_safe(ims)
|
||
if ims_ts:
|
||
ims_dt = dt.datetime.utcfromtimestamp(ims_ts).astimezone(
|
||
dt.timezone.utc
|
||
)
|
||
if lm <= ims_dt:
|
||
return Response(status=304)
|
||
resp = Response(cached["data"])
|
||
for k, v in cached["headers"].items():
|
||
resp.headers[k] = v
|
||
return resp
|
||
|
||
# Фильтрация кандидатов: one-time в диапазоне + annual по (month, day)
|
||
qs = CompanyEvent.objects.only(
|
||
"id", "title", "description", "date", "recurrence", "color", "location"
|
||
)
|
||
qs = qs.annotate(month=ExtractMonth("date"), day=ExtractDay("date"))
|
||
|
||
sm, sd = start.month, start.day
|
||
em, ed = end.month, end.day
|
||
one_time_q = Q(recurrence=Recurrence.ONE_TIME, date__range=(start, end))
|
||
if (sm, sd) <= (em, ed):
|
||
ge_start = Q(month__gt=sm) | (Q(month=sm) & Q(day__gte=sd))
|
||
le_end = Q(month__lt=em) | (Q(month=em) & Q(day__lte=ed))
|
||
annual_q = Q(recurrence=Recurrence.ANNUAL) & ge_start & le_end
|
||
else:
|
||
ge_start = Q(month__gt=sm) | (Q(month=sm) & Q(day__gte=sd))
|
||
le_end = Q(month__lt=em) | (Q(month=em) & Q(day__lte=ed))
|
||
annual_q = Q(recurrence=Recurrence.ANNUAL) & (ge_start | le_end)
|
||
|
||
queryset = qs.filter(one_time_q | annual_q)
|
||
|
||
# Разворачиваем annual → occurrence’ы
|
||
items: List[CompanyEvent] = []
|
||
for ev in queryset:
|
||
if ev.recurrence == Recurrence.ONE_TIME:
|
||
inst = copy.copy(ev)
|
||
inst.occurrence_date = ev.date
|
||
items.append(inst)
|
||
else:
|
||
for year in range(start.year, end.year + 1):
|
||
try:
|
||
occ = ev.date.replace(year=year)
|
||
except ValueError:
|
||
if ev.date.month == 2 and ev.date.day == 29:
|
||
occ = dt.date(year, 2, 28)
|
||
else:
|
||
continue
|
||
if start <= occ <= end:
|
||
inst = copy.copy(ev)
|
||
inst.occurrence_date = occ
|
||
items.append(inst)
|
||
|
||
items.sort(key=lambda o: getattr(o, "occurrence_date", o.date))
|
||
if len(items) > 2000:
|
||
items = items[:2000]
|
||
|
||
data = CompanyEventSerializer(items, many=True).data
|
||
etag = etag_for(cache_key, lm, len(data))
|
||
headers = {**RESPONSE_HEADERS}
|
||
if lm:
|
||
headers["Last-Modified"] = http_date(lm.timestamp())
|
||
headers["ETag"] = etag
|
||
|
||
cache.set(
|
||
cache_key, {"data": data, "headers": headers, "etag": etag}, CACHE_TTL
|
||
)
|
||
resp = Response(data)
|
||
for k, v in headers.items():
|
||
resp.headers[k] = v
|
||
return resp
|
||
|
||
|
||
class DepartmentEventsViewSet(ViewSet):
|
||
"""
|
||
GET /api/v1/calendar/departments/<int:pk>/events/?start=...&end=...
|
||
Доступ: HR / руководитель отдела / сотрудник отдела.
|
||
Разворачивает annual c сохранением длительности.
|
||
"""
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
def _dept_id(self) -> int:
|
||
try:
|
||
return int(self.kwargs["pk"])
|
||
except Exception:
|
||
raise Http404
|
||
|
||
def initial(self, request, *args, **kwargs):
|
||
super().initial(request, *args, **kwargs)
|
||
dept_id = self._dept_id()
|
||
if not can_view_department(request.user, dept_id):
|
||
raise Http404("Нет доступа к календарю этого отдела.")
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
start, end = get_range(request)
|
||
dept_id = self._dept_id()
|
||
|
||
base = (DepartmentEvent.objects
|
||
.filter(department_id=dept_id)
|
||
.select_related("department")
|
||
.only("id","title","description","start_date","end_date",
|
||
"all_day","recurrence","color","location","department","created_at"))
|
||
lm = last_modified(base, DepartmentEvent)
|
||
|
||
if not (start and end):
|
||
data = DepartmentEventSerializer(base.order_by("start_date")[:1000], many=True).data
|
||
resp = Response(data)
|
||
for k, v in RESPONSE_HEADERS.items(): resp.headers[k] = v
|
||
return resp
|
||
|
||
cache_key = f"cal:v1:dept:{dept_id}:{request.user.id}:{start.isoformat()}:{end.isoformat()}"
|
||
cached = cache.get(cache_key)
|
||
if cached:
|
||
if request.META.get("HTTP_IF_NONE_MATCH") == cached["etag"]:
|
||
return Response(status=304)
|
||
ims = request.META.get("HTTP_IF_MODIFIED_SINCE")
|
||
if ims and lm:
|
||
ims_ts = parse_http_date_safe(ims)
|
||
if ims_ts:
|
||
ims_dt = dt.datetime.utcfromtimestamp(ims_ts).astimezone(dt.timezone.utc)
|
||
if lm <= ims_dt:
|
||
return Response(status=304)
|
||
resp = Response(cached["data"])
|
||
for k, v in cached["headers"].items(): resp.headers[k] = v
|
||
return resp
|
||
|
||
# ONE_TIME: overlap
|
||
one_time = base.filter(
|
||
Q(recurrence=Recurrence.ONE_TIME) &
|
||
(Q(end_date__isnull=True, start_date__range=(start, end)) |
|
||
Q(end_date__isnull=False, end_date__gte=start, start_date__lte=end))
|
||
).order_by("start_date")
|
||
|
||
# ANNUAL: разворачивание (с длительностью)
|
||
annual = base.filter(recurrence=Recurrence.ANNUAL)
|
||
|
||
items: List[DepartmentEvent] = []
|
||
for ev in one_time:
|
||
obj = copy.copy(ev)
|
||
obj.occ_start_date = ev.start_date
|
||
obj.occ_end_date = ev.end_date or ev.start_date
|
||
items.append(obj)
|
||
|
||
for ev in annual:
|
||
s = ev.start_date; e = ev.end_date or ev.start_date
|
||
dur = (e - s).days
|
||
for year in range(start.year, end.year + 1):
|
||
try:
|
||
s_y = s.replace(year=year)
|
||
except ValueError:
|
||
if s.month == 2 and s.day == 29:
|
||
s_y = dt.date(year, 2, 28)
|
||
else:
|
||
continue
|
||
e_y = s_y + dt.timedelta(days=dur)
|
||
if not (e_y < start or s_y > end):
|
||
obj = copy.copy(ev)
|
||
obj.occ_start_date = s_y
|
||
obj.occ_end_date = e_y
|
||
items.append(obj)
|
||
|
||
items.sort(key=lambda o: getattr(o, "occ_start_date", o.start_date))
|
||
if len(items) > 2000:
|
||
items = items[:2000]
|
||
|
||
data = DepartmentEventSerializer(items, many=True).data
|
||
etag = etag_for(cache_key, lm, len(data))
|
||
headers = {**RESPONSE_HEADERS}
|
||
if lm: headers["Last-Modified"] = http_date(lm.timestamp())
|
||
headers["ETag"] = etag
|
||
|
||
cache.set(cache_key, {"data": data, "headers": headers, "etag": etag}, CACHE_TTL)
|
||
resp = Response(data)
|
||
for k, v in headers.items(): resp.headers[k] = v
|
||
return resp |