262 lines
11 KiB
Python

# backend/calendar_app/views.py
from __future__ import annotations
import copy, hashlib, datetime as dt
from typing import Tuple, List
from django.db.models import Q, Max
from django.db.models.functions import ExtractDay, ExtractMonth
from django.http import Http404
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.http import http_date, parse_http_date_safe
from django.utils import timezone
from django.core.cache import cache
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.renderers import JSONRenderer
from .models import CompanyEvent, DepartmentEvent, Recurrence
from .serializers import CompanyEventSerializer, DepartmentEventSerializer
# ---- Тюнинги ----
MAX_WINDOW_DAYS = 400
MAX_OCCURRENCES = 2000
CACHE_TTL = 60
RESPONSE_HEADERS = {"Cache-Control": f"private, max-age={CACHE_TTL}"}
def _parse_to_date(value: str | None) -> dt.date | None:
if not value:
return None
dtime = parse_datetime(value)
return dtime.date() if dtime else parse_date(value)
def _get_range(request) -> Tuple[dt.date | None, dt.date | None]:
s = request.query_params.get("start") or request.query_params.get("from")
e = request.query_params.get("end") or request.query_params.get("to")
if not (s and e):
return None, None
start = _parse_to_date(s); end = _parse_to_date(e)
if not (start and end):
return None, None
if end < start:
start, end = end, start
if (end - start).days > MAX_WINDOW_DAYS:
end = start + dt.timedelta(days=MAX_WINDOW_DAYS)
return start, end
def _etag_for(payload_key: str, last_modified: dt.datetime | None, count: int) -> str:
ts = int(last_modified.timestamp()) if last_modified else 0
return hashlib.sha1(f"{payload_key}:{ts}:{count}".encode()).hexdigest()
def _last_modified_for_model(qs, model):
# корректно берём поле: updated_at если есть, иначе created_at
fields = {f.name for f in model._meta.get_fields() if hasattr(f, "name")}
lm_field = "updated_at" if "updated_at" in fields else "created_at"
return qs.aggregate(m=Max(lm_field)).get("m")
# ================= COMPANY =================
class CompanyEventListAPI(generics.ListAPIView):
serializer_class = CompanyEventSerializer
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def _annual_qs_filtered(self, start: dt.date, end: dt.date):
sm, sd = start.month, start.day
em, ed = end.month, end.day
annual = CompanyEvent.objects.filter(recurrence=Recurrence.ANNUAL) \
.annotate(month=ExtractMonth("date"), day=ExtractDay("date")) \
.only("id","title","description","date","recurrence","color","location")
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))
return annual.filter(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))
return annual.filter(ge_start | le_end)
def list(self, request, *args, **kwargs):
start, end = _get_range(request)
base_qs = CompanyEvent.objects.only("id","title","description","date","recurrence","color","location")
# Без диапазона — просто отдадим (обычно FullCalendar всегда шлёт окно)
if not (start and end):
data = CompanyEventSerializer(base_qs.order_by("date")[:MAX_OCCURRENCES], many=True).data
return Response(data, headers=RESPONSE_HEADERS)
lm = _last_modified_for_model(base_qs, CompanyEvent)
cache_key = f"cal:company:{request.user.id}:{start.isoformat()}:{end.isoformat()}"
cached = cache.get(cache_key)
if cached:
# 304 по ETag/IMS
inm = request.META.get("HTTP_IF_NONE_MATCH")
if inm and inm == cached.get("etag"):
return Response(status=304, headers={"ETag": inm, **RESPONSE_HEADERS})
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).replace(tzinfo=timezone.utc)
if lm <= ims_dt:
h = {"Last-Modified": http_date(lm.timestamp()), **RESPONSE_HEADERS}
return Response(status=304, headers=h)
return Response(cached["data"], headers=cached["headers"])
# ONE_TIME в окне
one_time = base_qs.filter(recurrence=Recurrence.ONE_TIME, date__range=(start, end))
# ANNUAL по (month, day)
annual = self._annual_qs_filtered(start, end)
# Разворачиваем
items: List[CompanyEvent] = []
for ev in one_time:
inst = copy.copy(ev)
inst.occurrence_date = ev.date
items.append(inst)
for ev in annual:
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) > MAX_OCCURRENCES:
items = items[:MAX_OCCURRENCES]
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)
return Response(data, headers=headers)
# ================= DEPARTMENT =================
class DepartmentEventListAPI(generics.ListAPIView):
serializer_class = DepartmentEventSerializer
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def _dept_id(self) -> int:
try:
return int(self.kwargs["pk"])
except Exception:
raise Http404
def _check_access(self, user, dept_id: int) -> None:
if user.is_active and (
user.groups.filter(name="HR").exists()
or user.has_perm("requests_app.can_view_all_requests")
or user.has_perm("requests_app.can_process_requests")
or user.headed_departments.filter(pk=dept_id).exists()
or user.departments_links.filter(department_id=dept_id, is_active=True).exists()
):
return
raise Http404("Нет доступа к календарю этого отдела.")
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
self._check_access(request.user, self._dept_id())
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_for_model(base, DepartmentEvent)
if not (start and end):
data = DepartmentEventSerializer(base.order_by("start_date")[:MAX_OCCURRENCES], many=True).data
return Response(data, headers=RESPONSE_HEADERS)
cache_key = f"cal:dept:{dept_id}:{request.user.id}:{start.isoformat()}:{end.isoformat()}"
cached = cache.get(cache_key)
if cached:
inm = request.META.get("HTTP_IF_NONE_MATCH")
if inm and inm == cached.get("etag"):
return Response(status=304, headers={"ETag": inm, **RESPONSE_HEADERS})
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).replace(tzinfo=timezone.utc)
if lm <= ims_dt:
h = {"Last-Modified": http_date(lm.timestamp()), **RESPONSE_HEADERS}
return Response(status=304, headers=h)
return Response(cached["data"], headers=cached["headers"])
# 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 expand
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) > MAX_OCCURRENCES:
items = items[:MAX_OCCURRENCES]
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)
return Response(data, headers=headers)