262 lines
11 KiB
Python
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)
|