diff --git a/backend/api.zip b/backend/api.zip deleted file mode 100644 index 98db1c9e..00000000 Binary files a/backend/api.zip and /dev/null differ diff --git a/backend/api/v1/calendar/serializers.py b/backend/api/v1/calendar/serializers.py index 13d70e1d..90f105f4 100644 --- a/backend/api/v1/calendar/serializers.py +++ b/backend/api/v1/calendar/serializers.py @@ -1,164 +1,204 @@ -# backend/calendar_app/serializers.py -import datetime as dt +# backend/api/v1/calendar/serializers.py +from __future__ import annotations -from django.utils import timezone -from django.utils.dateparse import parse_date, parse_datetime +import re +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, List, Optional + +from calendar_app.models import CalendarEvent, Recurrence +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from calendar_app.models import CompanyEvent, DepartmentEvent, Recurrence +class CalendarEventWriteSerializer(serializers.ModelSerializer): + """Создание/обновление события календаря с полной валидацией.""" -def _aware(dt_or_date: dt.datetime | dt.date, to_utc: bool = False) -> dt.datetime: - """ - Делает datetime aware в текущем TZ (или переводит в UTC). - Если пришла date — берём локальную полночь. - """ - if isinstance(dt_or_date, dt.date) and not isinstance(dt_or_date, dt.datetime): - dtime = dt.datetime.combine(dt_or_date, dt.time.min) - else: - dtime = dt_or_date # type: ignore[assignment] + # Разрешаем пустой список для PATCH (когда меняют weekly -> daily и присылают weekdays: []) + weekdays = serializers.ListField( + child=serializers.IntegerField(min_value=0, max_value=6), + required=False, + allow_empty=True, + write_only=True, + help_text="Список дней недели для WEEKLY (0=Mon..6=Sun).", + ) + department_id = serializers.IntegerField(required=False, allow_null=True) - if timezone.is_naive(dtime): - dtime = timezone.make_aware(dtime, timezone.get_current_timezone()) + class Meta: + model = CalendarEvent + fields = ( + "id", + "department_id", + "title", + "description", + "start_date", + "end_date", + "start_time", + "end_time", + "all_day", + "recurrence", + "recurrence_interval", + "recurrence_until", + "recurrence_count", + "weekdays_mask", + "weekdays", # write-only + "color", + "location", + ) + read_only_fields = ("id",) - if to_utc: - dtime = timezone.localtime(dtime, timezone.utc) - return dtime + # ---- helpers + def _coalesce(self, data: Dict[str, Any], instance: Optional[CalendarEvent], key: str, default=None): + if key in data: + return data.get(key) + if instance is not None: + return getattr(instance, key) + return default + @staticmethod + def _weekdays_to_mask(values: list[int]) -> int: + mask = 0 + for v in values: + iv = int(v) + if not (0 <= iv <= 6): + raise ValueError("weekday out of range") + mask |= (1 << iv) + return mask -def _as_iso(value, *, all_day: bool = True, to_utc: bool = False) -> str | None: - """ - Приводит value (date | datetime | str | None) к ISO-строке. - - Для all-day отдаём YYYY-MM-DD (без времени — так рекомендует FullCalendar). - - Для timed делаем aware datetime (локальный TZ по умолчанию или UTC). - """ - if value is None: - return None + # ---- validation + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + inst: Optional[CalendarEvent] = getattr(self, "instance", None) + is_create = inst is None - # datetime - if isinstance(value, dt.datetime): - dtime = _aware(value, to_utc=to_utc) - return dtime.isoformat() + s_date: date = self._coalesce(attrs, inst, "start_date") + if not s_date: + raise serializers.ValidationError({"start_date": _("Обязательное поле.")}) - # date - if isinstance(value, dt.date): + e_date: Optional[date] = self._coalesce(attrs, inst, "end_date", None) + s_time: Optional[time] = self._coalesce(attrs, inst, "start_time", None) + e_time: Optional[time] = self._coalesce(attrs, inst, "end_time", None) + + all_day_raw = self._coalesce(attrs, inst, "all_day", None) + rec: str = self._coalesce(attrs, inst, "recurrence", Recurrence.ONE_TIME) + + # ВАЖНО: не используем "or 1", чтобы 0 не превратился в 1 + interval_val = self._coalesce(attrs, inst, "recurrence_interval", 1) + try: + interval = int(interval_val) + except Exception: + raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть числом.")}) + + until: Optional[date] = self._coalesce(attrs, inst, "recurrence_until", None) + rcount: Optional[int] = self._coalesce(attrs, inst, "recurrence_count", None) + + wmask: int = int(self._coalesce(attrs, inst, "weekdays_mask", 0)) + weekdays_input = attrs.get("weekdays", None) + if weekdays_input is not None: + try: + wmask = self._weekdays_to_mask(weekdays_input) + except Exception: + raise serializers.ValidationError({"weekdays": _("Неверный формат дней недели (ожидаются числа 0..6).")}) + attrs["weekdays_mask"] = wmask + attrs.pop("weekdays", None) + + color: Optional[str] = self._coalesce(attrs, inst, "color", None) + + # 1) База + if interval < 1: + raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть ≥ 1.")}) + if until is not None and until < s_date: + raise serializers.ValidationError({"recurrence_until": _("Дата окончания повторения не может быть раньше даты начала.")}) + # Конфликтующие параметры + if until is not None and rcount is not None: + raise serializers.ValidationError({"recurrence_count": _("Нельзя одновременно задавать recurrence_until и recurrence_count.")}) + + if color and not re.fullmatch(r"#([0-9A-Fa-f]{6})", color): + raise serializers.ValidationError({"color": _("Цвет должен быть в формате #RRGGBB.")}) + + # 2) Время / all_day + has_any_time = (s_time is not None) or (e_time is not None) + if has_any_time and (s_time is None or e_time is None): + raise serializers.ValidationError(_("Если указываете время, заполните и начало, и окончание.")) + + if all_day_raw is None: + attrs["all_day"] = not has_any_time + else: + if all_day_raw and has_any_time: + raise serializers.ValidationError(_("Нельзя указывать время для события на весь день.")) + if (all_day_raw is False) and not has_any_time: + raise serializers.ValidationError(_("Для события с временем укажите start_time и end_time.")) + + all_day: bool = attrs.get("all_day", inst.all_day if inst else (not has_any_time)) + + # end_date по умолчанию = start_date + if e_date is None: + e_date = s_date + attrs["end_date"] = e_date + + # 3) Хронология if all_day: - return value.isoformat() - return _aware(value, to_utc=to_utc).isoformat() + if e_date < s_date: + raise serializers.ValidationError({"end_date": _("Дата окончания не может быть раньше даты начала.")}) + else: + start_dt = datetime.combine(s_date, s_time) + end_dt = datetime.combine(e_date, e_time) + if end_dt <= start_dt: + raise serializers.ValidationError({"end_time": _("Окончание должно быть позже начала.")}) - # str - if isinstance(value, str): - dtime = parse_datetime(value) - if dtime: - return _aware(dtime, to_utc=to_utc).isoformat() - d = parse_date(value) - if d: - return d.isoformat() if all_day else _aware(d, to_utc=to_utc).isoformat() - return value # оставим как есть, чтобы не ронять API + # 4) Повторяемость + if rec == Recurrence.HOURLY and all_day: + raise serializers.ValidationError({"recurrence": _("Ежечасное повторение возможно только для событий с временем.")}) - return None + if rec == Recurrence.WEEKLY: + # create: требуем явные дни + if is_create and wmask == 0: + raise serializers.ValidationError({"weekdays_mask": _("Для еженедельного события укажите дни недели (weekdays или weekdays_mask).")}) + # update: если маска не пришла и осталась 0 — подставим день недели старта + if (not is_create) and ("weekdays_mask" not in attrs) and (wmask == 0): + wmask = 1 << s_date.weekday() + attrs["weekdays_mask"] = wmask + if wmask < 0 or wmask > 0b1111111: + raise serializers.ValidationError({"weekdays_mask": _("Недопустимая маска дней недели.")}) + else: + # для НЕ weekly игнорируем присланные weekdays/маску (если вдруг присланы пустыми) + attrs.pop("weekdays", None) + # маску не трогаем, если не прислана — оставляем как есть/0 + + if rec not in Recurrence.values: + raise serializers.ValidationError({"recurrence": _("Недопустимый тип повторения.")}) + + if rcount is not None and rcount < 1: + raise serializers.ValidationError({"recurrence_count": _("Количество повторов должно быть ≥ 1.")}) + + return attrs + + def create(self, validated_data: Dict[str, Any]) -> CalendarEvent: + validated_data.pop("weekdays", None) + return CalendarEvent.objects.create(**validated_data) + + def update(self, instance: CalendarEvent, validated_data: Dict[str, Any]) -> CalendarEvent: + validated_data.pop("weekdays", None) + for f, v in validated_data.items(): + setattr(instance, f, v) + instance.save() + return instance -class CompanyEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() +class CalendarEventSerializer(serializers.ModelSerializer): + """Read-сериализатор (база модели, если нужен просмотр без развёртки).""" class Meta: - model = CompanyEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "url", - ) - read_only_fields = fields - - def get_start(self, obj: CompanyEvent): - # Однодневное корпоративное событие - occ = getattr(obj, "occurrence_date", None) - return _as_iso(occ or obj.date, all_day=True) - - def get_end(self, obj: CompanyEvent): - # Для all-day можно вернуть None (FullCalendar корректно отобразит один день) - return None - - def get_allDay(self, obj: CompanyEvent): - return True - - def get_url(self, obj: CompanyEvent): - # Если детальной страницы нет — можно вернуть календарь компании - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: CompanyEvent): - return obj.get_recurrence_display() + model = CalendarEvent + fields = "__all__" -class DepartmentEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() - department_name = serializers.SerializerMethodField() +class EventOccurrenceSerializer(serializers.Serializer): + """Сериализатор одного материализованного вхождения для FullCalendar.""" - class Meta: - model = DepartmentEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "department", # id отдела - "department_name", - "url", - ) - read_only_fields = fields - - def get_start(self, obj): - start = getattr(obj, "occ_start_date", obj.start_date) - return _as_iso(start, all_day=obj.all_day) - - def get_end(self, obj): - end = getattr(obj, "occ_end_date", obj.end_date) - if not end: - return None - if obj.all_day: - # end-exclusive для all-day - return _as_iso(end + dt.timedelta(days=1), all_day=True) - return _as_iso(end, all_day=False) - - def get_allDay(self, obj: DepartmentEvent): - return bool(obj.all_day) - - def get_url(self, obj: DepartmentEvent): - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: DepartmentEvent): - return obj.get_recurrence_display() - - def get_department_name(self, obj: DepartmentEvent): - # избежим N+1: в view делайте select_related("department") - dept = getattr(obj, "department", None) - return getattr(dept, "name", None) + id = serializers.IntegerField() + title = serializers.CharField() + start = serializers.DateTimeField(required=False) + end = serializers.DateTimeField(required=False) + allDay = serializers.BooleanField() + color = serializers.CharField(allow_null=True, required=False) + recurrence = serializers.CharField() + department_id = serializers.IntegerField(allow_null=True, required=False) diff --git a/backend/api/v1/calendar/views.py b/backend/api/v1/calendar/views.py index c40c1d19..db800e3b 100644 --- a/backend/api/v1/calendar/views.py +++ b/backend/api/v1/calendar/views.py @@ -1,251 +1,216 @@ -# backend/api/v1/calendar/viewsets.py from __future__ import annotations -import copy -import datetime as dt -from typing import List +from datetime import date +from typing import Optional, Type, List, Dict, Any 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 django.utils.dateparse import parse_date +from rest_framework import serializers, status 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 rest_framework.viewsets import ModelViewSet -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, +from calendar_app.models import CalendarEvent +from .serializers import ( + CalendarEventSerializer, + CalendarEventWriteSerializer, ) +from calendar_app.services.recurrence import expand_event_occurrences + +from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed # пермишены для "компании" +from employees.constants import DeptPerm # коды прав отделов +import logging +logger = logging.getLogger(__name__) -class CompanyEventsViewSet(ViewSet): - """ - GET /api/v1/calendar/company-events/?start=YYYY-MM-DD&end=YYYY-MM-DD - Разворачивает ежегодные события в вхождения внутри окна. +class CalendarEventsViewSet(ModelViewSet): + """Единый CRUD по событиям календаря (компания/отдел) с развёрткой повторяемости. + + list: + Возвращает МАТЕРИАЛИЗОВАННЫЕ вхождения в окне [start, end). + Если указан department_id — события отдела, иначе — компании. + + retrieve/create/update/partial_update/destroy: + Работают с объектом CalendarEvent. """ - permission_classes = [IsAuthenticated] + queryset = CalendarEvent.objects.select_related("department").order_by( + "start_date", "start_time" + ) 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 + class ManageCalendarPerm(AdminOrDeptAllowed): + """Право на управление календарём отдела (для компании молчит).""" - 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 + required_code = DeptPerm.MANAGE_CALENDAR - # Фильтрация кандидатов: 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) + def get_permissions(self): + """Для компании — AdminOrActionOrModelPerms, для отдела — ManageCalendarPerm. - queryset = qs.filter(one_time_q | annual_q) + ВАЖНО: прокидываем найденный department_id в kwargs, чтобы пермишен + гарантированно его увидел на этапе has_permission(). + """ + dep = self._dept_id(required=False) + print(f"Request user: {self.request.user}, Auth: {self.request.auth}") + if dep is None: + + return [AdminOrActionOrModelPerms()] - # Разворачиваем 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): + # События отдела: подложим dep в kwargs (если его не было) + # чтобы AdminOrDeptAllowed нашёл department_id без чтения request.data + self.kwargs = dict(self.kwargs) # скопировать, т.к. может быть MappingProxy + self.kwargs.setdefault("department_id", dep) + + return [self.ManageCalendarPerm()] + + # ---------- Сериализаторы ---------- + + def get_serializer_class(self) -> Type[serializers.Serializer]: + """Выбор сериализатора по действию. + + Returns: + Type[serializers.Serializer]: Класс сериализатора. + """ + if self.action in {"create", "update", "partial_update"}: + return CalendarEventWriteSerializer + return CalendarEventSerializer + + # ---------- Утилиты ---------- + + def _dept_id(self, *, required: bool = False) -> Optional[int]: + """Читает department_id из kwargs/query/body. + + ВАЖНО: *не* путать pk события с department_id. + + Args: + required (bool): Если True — бросить 404 при отсутствии/ошибке. + + Raises: + Http404: При некорректном или отсутствующем department_id (когда required=True). + + Returns: + Optional[int]: PK отдела или None (компания). + """ + # nested-url вида: /departments/{department_id}/events/ + for key in ("department_pk", "department_id"): + if key in self.kwargs: + try: + return int(self.kwargs[key]) + except Exception: + raise Http404("Некорректный department_id в URL.") + + req = getattr(self, "request", None) + if req is not None: + for key in ("department_id", "department"): + val = req.query_params.get(key) + if val is not None: 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) + return int(val) + except Exception: + raise Http404("Некорректный department_id в query.") - items.sort(key=lambda o: getattr(o, "occurrence_date", o.date)) - if len(items) > 2000: - items = items[:2000] + data = getattr(req, "data", {}) if req is not None else {} + if isinstance(data, dict): + for key in ("department_id", "department"): + if key in data and data[key] is not None: + try: + return int(data[key]) + except Exception: + raise Http404("Некорректный department_id в теле запроса.") - 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 + if required: + raise Http404("Не указан department_id.") + return None - 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 + # ---------- QuerySet ---------- + def get_queryset(self): + """QS для list фильтруем по компании/отделу, для detail — не фильтруем. -class DepartmentEventsViewSet(ViewSet): - """ - GET /api/v1/calendar/departments//events/?start=...&end=... - Доступ: HR / руководитель отдела / сотрудник отдела. - Разворачивает annual c сохранением длительности. - """ - permission_classes = [IsAuthenticated] - renderer_classes = [JSONRenderer] + Returns: + QuerySet: Набор для текущего действия. + """ + qs = super().get_queryset() + if getattr(self, "action", None) != "list": + # detail — нужен полный QS, чтобы по pk находить любой объект + return qs - def _dept_id(self) -> int: - try: - return int(self.kwargs["pk"]) - except Exception: - raise Http404 + dep = self._dept_id(required=False) + return qs.filter(department__isnull=True) if dep is None else qs.filter(department_id=dep) - 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("Нет доступа к календарю этого отдела.") + # ---------- LIST: материализация повторов ---------- def list(self, request, *args, **kwargs): - start, end = get_range(request) - dept_id = self._dept_id() + """Возвращает материализованные вхождения в окне [start, end). - 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) + Query params: + start (YYYY-MM-DD) — левая граница (включительно). + end (YYYY-MM-DD) — правая граница (исключительно). - 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 + Returns: + Response: Массив объектов для FullCalendar. + """ + start_str = request.query_params.get("start") + end_str = request.query_params.get("end") + if not start_str or not end_str: + return Response([], status=status.HTTP_200_OK) - 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 + r_start: Optional[date] = parse_date(start_str[:10]) + r_end: Optional[date] = parse_date(end_str[:10]) + if not r_start or not r_end: + return Response( + {"detail": "Некорректные параметры start/end."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if r_start >= r_end: + return Response([], status=status.HTTP_200_OK) - # 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") + items: List[Dict[str, Any]] = [] + for ev in self.get_queryset(): + for oc in expand_event_occurrences( + ev, range_start=r_start, range_end=r_end, max_instances=1000 + ): + items.append( + { + "id": ev.id, # идентификатор «родителя»; можно заменить на oc.id, если нужен уникальный на каждое вхождение + "title": ev.title, + "start": oc.start.isoformat(), + "end": oc.end.isoformat(), + "allDay": oc.all_day, + "color": ev.color or None, + "recurrence": ev.recurrence, + "department_id": ev.department_id, + } + ) + return Response(items, status=status.HTTP_200_OK) - # 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) + def perform_create(self, serializer: CalendarEventWriteSerializer) -> None: + """Сохраняет объект, подставляя department_id из контекста (если есть) и автора. - 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) + Raises: + ValidationError: Если сериализатор невалиден (поднимет сам DRF). + """ + dep = self._dept_id(required=False) + serializer.save( + department_id=dep if dep is not None else serializer.validated_data.get("department_id"), + created_by=self.request.user, + ) + cache.clear() - items.sort(key=lambda o: getattr(o, "occ_start_date", o.start_date)) - if len(items) > 2000: - items = items[:2000] + def perform_update(self, serializer: CalendarEventWriteSerializer) -> None: + """Обновляет событие и чистит кеш.""" + serializer.save() + cache.clear() - 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 \ No newline at end of file + def perform_destroy(self, instance: CalendarEvent) -> None: + """Удаляет событие и чистит кеш.""" + instance.delete() + cache.clear() \ No newline at end of file diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 2c668587..7228e33b 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -42,20 +42,16 @@ from rest_framework import filters, generics, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import ( AllowAny, - DjangoModelPermissions, - IsAdminUser, IsAuthenticated, ) from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_simplejwt.authentication import JWTAuthentication + from ..permissions import ( - ASSIGN_ROLE_PERM, - MANAGE_PERM, + AdminOrActionOrModelPerms, AdminOrDeptAllowed, - IsDeptManagerForWrite, IsSelfOrStaff, has_dept_perm, ) diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index f6ff2a0e..e9bded58 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -1,6 +1,8 @@ # backend\api\v1\permissions.py from __future__ import annotations +from typing import Any, Dict, Optional + from django.contrib.auth.models import AbstractBaseUser from employees.constants import DeptPerm from employees.models import Department, EmployeeDepartment @@ -72,58 +74,82 @@ def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool return link.role.scoped_permissions.filter(code=code).exists() -class IsDeptManagerForWrite(BasePermission): - """ - Для DepartmentViewSet. - View может указать: - self.required_perm_code in {"change_department_head", "assign_department_role", "manage_department"} - По умолчанию — "manage_department". - """ - - message = "Недостаточно прав для управления отделом." - - def has_object_permission(self, request, view, obj): - dept = getattr(obj, "department", obj) - if request.method in SAFE_METHODS: - return True - - user = request.user - if not user or not user.is_authenticated: - return False - - if user_is_staffish(user) or user_is_dept_head(user, dept): - return True - - perm_code = getattr(view, "required_perm_code", MANAGE_PERM) - - # NEW: если у пользователя есть глобальный модельный пермишен (employees.) — пускаем - # (сбросим кэш прав — в тестах перм выдают после первого запроса) - for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"): - if hasattr(user, attr): - try: - delattr(user, attr) - except Exception: - pass - app_label = Department._meta.app_label # "employees" - if user.has_perm(f"{app_label}.{perm_code}"): - return True - - # иначе — проверяем наличие нужного права через роль в данном отделе - return user_has_dept_perm(user, dept, perm_code) - - class AdminOrDeptAllowed(BasePermission): - required_code: str | None = None - required_code_map: dict[str, str] = {} + """Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел. + + Поведение: + 1. Если пользователь superuser/staff — доступ всегда разрешён. + 2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR), + то доступ разрешается только при наличии у пользователя этого права на указанный отдел. + 3. Идентификатор отдела извлекается из: + - URL kwargs: 'department_pk' → 'department_id'; + - request.data: 'department' → 'department_id'; + - query params: 'department' → 'department_id'. + На уровне has_object_permission отдел берётся из самого объекта: + - Department.id; + - obj.department_id / obj.dept_id; + - obj.department.id. + 4. Если код права не задан (required_code/required_code_map пусты), то + для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию). + Для небезопасных методов в этом случае доступ запрещён. + + Атрибуты класса: + required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map. + required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...). + allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True. + + Примеры: + class ManageCalendarPerm(AdminOrDeptAllowed): + required_code = DeptPerm.MANAGE_CALENDAR + + class DeptEventsPerm(AdminOrDeptAllowed): + required_code_map = { + "create": DeptPerm.MANAGE_CALENDAR, + "update": DeptPerm.MANAGE_CALENDAR, + "partial_update": DeptPerm.MANAGE_CALENDAR, + "destroy": DeptPerm.MANAGE_CALENDAR, + } + """ + + required_code: Optional[str] = None + required_code_map: Dict[str, str] = {} allow_safe_without_code: bool = True - def get_required_code(self, request, view): + # -------- helpers -------- + + def get_required_code(self, request, view) -> Optional[str]: + """Определяет требуемый код права для текущего экшена. + + Сначала берёт из required_code_map по имени action (DRF: view.action), + затем fallback на required_code. + + Args: + request: DRF Request (не используется в текущей логике). + view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet. + + Returns: + Optional[str]: Код права или None, если право для экшена не требуется. + """ return ( self.required_code_map.get(getattr(view, "action", None)) or self.required_code ) - def _extract_dept_id_from_request(self, request, view): + def _extract_dept_id_from_request(self, request, view) -> Optional[int]: + """Пытается извлечь department_id из kwargs, body или query-параметров. + + Порядок: + 1) view.kwargs: department_pk, department_id + 2) request.data: department, department_id + 3) request.query_params: department, department_id + + Args: + request: DRF Request + view: DRF View + + Returns: + Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None. + """ for k in ("department_pk", "department_id"): v = view.kwargs.get(k) if v is not None: @@ -138,13 +164,24 @@ class AdminOrDeptAllowed(BasePermission): return v return None - def _extract_dept_id_from_obj(self, obj): + def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]: + """Извлекает department_id из объекта. + + Порядок: + - Если obj — модель Department с полем id → вернуть id. + - Если у obj есть атрибуты department_id или dept_id → вернуть его. + - Если у obj есть атрибут department (FK) → взять department.id. + + Args: + obj: Модель/объект detail-view. + + Returns: + Optional[int]: department_id или None. + """ if obj is None: return None - # сам отдел if obj.__class__.__name__ == "Department" and hasattr(obj, "id"): return obj.id - # прямое поле FK for attr in ("department_id", "dept_id"): if hasattr(obj, attr): return getattr(obj, attr) @@ -153,13 +190,41 @@ class AdminOrDeptAllowed(BasePermission): return getattr(dep, "id", None) return None - def _to_int_or_none(self, v): + def _to_int_or_none(self, v) -> Optional[int]: + """Аккуратное приведение к int. + + Args: + v: Любое значение с возможностью строкового преобразования. + + Returns: + Optional[int]: int или None при ошибке. + """ try: return int(str(v)) except Exception: return None - def has_permission(self, request, view): + # -------- main checks -------- + + def has_permission(self, request, view) -> bool: + """Проверка доступа на уровне запроса (до доступа к объекту). + + Правила: + - Неаутентифицированные → False. + - superuser/staff → True. + - Если код не требуется: + - SAFE-методы → allow_safe_without_code ? True : False + - небезопасные → False + - Если код требуется: + - Пытаемся найти department_id в запросе; + - Если нашли → проверяем has_dept_perm(user, dept_id, code); + - Если не нашли: + * если detail-вью → отложить проверку до has_object_permission (вернуть True); + * иначе → False. + + Returns: + bool: Разрешить/запретить доступ. + """ user = getattr(request, "user", None) if not user or not user.is_authenticated: return False @@ -183,7 +248,21 @@ class AdminOrDeptAllowed(BasePermission): return has_dept_perm(user, dept_id, code) - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj) -> bool: + """Проверка доступа к конкретному объекту (detail). + + Правила: + - superuser/staff → True. + - Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True). + - Иначе: + - Пытаемся извлечь department_id из объекта; + - если не удалось — пробуем извлечь из запроса (kwargs/data/query); + - если не нашли вообще → False; + - иначе → has_dept_perm(user, dept_id, code). + + Returns: + bool: Разрешить/запретить доступ к объекту. + """ user = getattr(request, "user", None) if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False): return True @@ -299,7 +378,6 @@ __all__ = [ "MANAGE_PERM", "CHANGE_HEAD_PERM", "ASSIGN_ROLE_PERM", - "IsDeptManagerForWrite", "IsSelfOrStaff", "IsAuthorOrStaffForComments", "AdminOrActionOrModelPerms", diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 1e11a954..5e341b81 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .calendar.views import CompanyEventsViewSet, DepartmentEventsViewSet +from .calendar.views import CalendarEventsViewSet from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet, EmployeeActionViewSet, EmployeeViewSet, GroupViewSet, PositionViewSet, @@ -12,9 +12,9 @@ from .feed.views import CommentViewSet, PostViewSet app_name = "v1" router = DefaultRouter() -router.register( - r"calendar/company-events", CompanyEventsViewSet, basename="company-events" -) + +router.register(r"calendar/events", CalendarEventsViewSet, basename="events") + router.register(r"departments", DepartmentViewSet, basename="departments") router.register(r"employees", EmployeeViewSet, basename="employees") router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions") @@ -25,14 +25,10 @@ router.register(r"posts", PostViewSet, basename="posts") router.register(r"comments", CommentViewSet, basename="comments") router.register(r"groups", GroupViewSet, basename="groups") + urlpatterns = [ path("auth/register/", RegisterAPIView.as_view(), name="register"), path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"), path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"), - path( - "calendar/departments//events/", - DepartmentEventsViewSet.as_view({"get": "list"}), - name="department-events", - ), path("", include(router.urls)), ] diff --git a/backend/bots/services/telegram.py b/backend/bots/services/telegram.py index 18898c9f..11ecf356 100644 --- a/backend/bots/services/telegram.py +++ b/backend/bots/services/telegram.py @@ -22,7 +22,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from django.contrib.auth import get_user_model -from calendar_app.models import CompanyEvent +from calendar_app.models import CalendarEvent from bots.models import BotSubscriber from bots.filters import IsBoundSubscriber_tg, IsEmployeeActive from documents.models import Document, DocumentAcknowledgement @@ -183,14 +183,14 @@ def _acknowledge(user, document): @sync_to_async def _get_one_time_events(date): - return list(CompanyEvent.objects.filter( - recurrence=CompanyEvent.ONE_TIME, date=date + return list(CalendarEvent.objects.filter( + recurrence=CalendarEvent.ONE_TIME, date=date )) @sync_to_async def _get_annual_events(): - return list(CompanyEvent.objects.filter(recurrence=CompanyEvent.ANNUAL)) + return list(CalendarEvent.objects.filter(recurrence=CalendarEvent.ANNUAL)) @sync_to_async diff --git a/backend/calendar_app/admin.py b/backend/calendar_app/admin.py index 03d81274..a814eb0e 100644 --- a/backend/calendar_app/admin.py +++ b/backend/calendar_app/admin.py @@ -1,26 +1,226 @@ -from django.contrib import admin -from .models import CompanyEvent +# backend/calendar_app/admin.py +from __future__ import annotations + +from typing import Iterable + +from django.contrib import admin, messages +from django.db.models import QuerySet +from django.http import HttpRequest +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from .models import CalendarEvent, Recurrence -@admin.register(CompanyEvent) -class CompanyEventAdmin(admin.ModelAdmin): - list_display = ('title', 'date', 'recurrence', 'created_by') - list_filter = ('recurrence', 'date', 'created_by') - search_fields = ('title', 'description', 'created_by__username', 'created_by__phone_number') - date_hierarchy = 'date' - ordering = ('recurrence', 'date') +class ScopeListFilter(admin.SimpleListFilter): + """Фильтр области события: компания или отдел. + + Values: + - 'company' → department IS NULL + - 'dept' → department IS NOT NULL + """ + title = _("Область") + parameter_name = "scope" + + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[tuple[str, str]]: + """Возвращает варианты выбора фильтра. + + Args: + request: Текущий запрос. + model_admin: Админ-класс. + + Returns: + Iterable[tuple[str, str]]: (value, label) + """ + return ( + ("company", _("Компания")), + ("dept", _("Отдел")), + ) + + def queryset(self, request: HttpRequest, queryset): + """Применяет фильтр к queryset. + + Args: + request: Текущий запрос. + queryset: Исходный queryset. + + Returns: + QuerySet: Отфильтрованный queryset. + """ + val = self.value() + if val == "company": + return queryset.filter(department__isnull=True) + if val == "dept": + return queryset.filter(department__isnull=False) + return queryset + + +class HasTimeListFilter(admin.SimpleListFilter): + """Фильтр наличия времени у события (есть оба time или нет ни одного).""" + title = _("Есть время") + parameter_name = "has_time" + + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[tuple[str, str]]: + """Опции фильтра (Да/Нет).""" + return ( + ("yes", _("Да")), + ("no", _("Нет")), + ) + + def queryset(self, request: HttpRequest, queryset): + """Применяет фильтр к queryset.""" + val = self.value() + if val == "yes": + return queryset.filter(start_time__isnull=False, end_time__isnull=False) + if val == "no": + return queryset.filter(start_time__isnull=True, end_time__isnull=True) + return queryset + + +@admin.register(CalendarEvent) +class CalendarEventAdmin(admin.ModelAdmin): + """Админка для модели CalendarEvent (события календаря компании и отделов).""" + + # ===== Список ===== + list_display = ( + "id", + "title", + "scope_display", + "start_date", + "end_date", + "start_time", + "end_time", + "all_day", + "recurrence", + "color_swatch", + "created_by", + "created_at", + ) + list_select_related = ("department", "created_by") + list_per_page = 50 + ordering = ("department_id", "start_date", "start_time") + + # ===== Поиск/Фильтры/Иерархия ===== + search_fields = ( + "title", + "description", + "location", + "department__name", + "department__title", + "department__code", + ) + list_filter = ( + "department", + "recurrence", + "all_day", + ("start_date", admin.DateFieldListFilter), + ("created_at", admin.DateFieldListFilter), + HasTimeListFilter, # <— ПЕРЕДАЁМ КЛАСС, НЕ СТРОКУ + ScopeListFilter, # <— ПЕРЕДАЁМ КЛАСС, НЕ СТРОКУ + ) + date_hierarchy = "start_date" + + # ===== Поля формы ===== + readonly_fields = ("created_at",) + raw_id_fields = ("department", "created_by") fieldsets = ( - (None, { - 'fields': ('title', 'description', 'date', 'recurrence') + (_("Основное"), { + "fields": ("title", "description", "department", "location"), }), - ('Meta', { - 'fields': ('created_by',), - 'classes': ('collapse',), + (_("Даты и время"), { + "fields": ( + ("start_date", "end_date"), + ("start_time", "end_time"), + "all_day", + ), + }), + (_("Отображение"), { + "fields": ("recurrence", "color"), + }), + (_("Служебное"), { + "fields": ("created_by", "created_at"), }), ) - readonly_fields = ('created_by',) - def save_model(self, request, obj, form, change): - if not obj.pk: + # ===== Экшены ===== + actions = ("make_all_day", "clear_time", "set_annual", "set_one_time") + + @admin.display(description=_("Область"), ordering="department_id") + def scope_display(self, obj: CalendarEvent) -> str: + """Возвращает 'Компания' или название отдела для строки списка. + + Args: + obj: Объект CalendarEvent. + + Returns: + str: Человекочитаемое имя области. + """ + return _("Компания") if obj.department_id is None else str(obj.department) + + @admin.display(description=_("Цвет")) + def color_swatch(self, obj: CalendarEvent) -> str: + """Отрисовывает mini-swatch цвета + hex-код. + + Args: + obj: Объект CalendarEvent. + + Returns: + str: HTML. + """ + if not obj.color: + return "—" + return format_html( + '{}', + obj.color, obj.color + ) + + # ===== Экшены логика ===== + @admin.action(description=_("Сделать целодневными (очистить время)")) + def make_all_day(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None: + """Делает выбранные события целодневными. + + Args: + request: Запрос администратора. + queryset: Выбранные события. + """ + updated = queryset.update(start_time=None, end_time=None, all_day=True) + self.message_user(request, _(f"Обновлено событий: {updated}"), level=messages.SUCCESS) + + @admin.action(description=_("Очистить время (оставить даты как есть)")) + def clear_time(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None: + """Очищает время начала/окончания у выбранных событий. + + Args: + request: Запрос администратора. + queryset: Выбранные события. + """ + updated = queryset.update(start_time=None, end_time=None) + self.message_user(request, _(f"Очищено время у событий: {updated}"), level=messages.SUCCESS) + + @admin.action(description=_("Пометить как ежегодные")) + def set_annual(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None: + """Ставит повторяемость 'Ежегодно' у выбранных событий.""" + updated = queryset.update(recurrence=Recurrence.ANNUAL) + self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS) + + @admin.action(description=_("Пометить как одноразовые")) + def set_one_time(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None: + """Ставит повторяемость 'Одноразовое' у выбранных событий.""" + updated = queryset.update(recurrence=Recurrence.ONE_TIME) + self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS) + + # ===== Сохранение ===== + def save_model(self, request: HttpRequest, obj: CalendarEvent, form, change: bool) -> None: + """Проставляет автора при первом сохранении. + + Args: + request: Текущий запрос. + obj: Объект события. + form: Форма админки. + change: True, если редактирование; False, если создание. + """ + if not change and obj.created_by_id is None: obj.created_by = request.user super().save_model(request, obj, form, change) diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py index 7dbe738a..9c70349e 100644 --- a/backend/calendar_app/apps.py +++ b/backend/calendar_app/apps.py @@ -1,8 +1,37 @@ -from django.apps import AppConfig +# backend/calendar_app/apps.py +from __future__ import annotations + +from django.apps import AppConfig, apps as django_apps +from django.db.models.signals import post_delete, post_save class CalendarAppConfig(AppConfig): - name = 'calendar_app' + """Конфиг приложения календаря. Подключает сигналы к модели сотрудника.""" + name = "calendar_app" + verbose_name = "Календарь" - def ready(self): - import calendar_app.signals # noqa + def ready(self) -> None: + """Подключает обработчики сигналов к employees.Employee. + + Raises: + LookupError: Если приложение 'employees' или модель 'Employee' не найдены. + """ + from calendar_app import signals # noqa: F401 (гарантирует импорт модулей с хендлерами) + + try: + Employee = django_apps.get_model("employees", "Employee") + except LookupError: + # Если в проекте иное имя модели — замените здесь. + return + + # Явно подключаем наши функции только к Employee + post_save.connect( + signals.handle_employee_saved, + sender=Employee, + dispatch_uid="calendar_app.employee_saved_sync_birthday", + ) + post_delete.connect( + signals.handle_employee_deleted, + sender=Employee, + dispatch_uid="calendar_app.employee_deleted_remove_birthday", + ) diff --git a/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py b/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py new file mode 100644 index 00000000..b655329b --- /dev/null +++ b/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.4 on 2025-09-09 07:03 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0003_alter_departmentevent_options'), + ('employees', '0024_alter_departmentpermission_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='departmentevent', + name='dept_event_date_range_valid', + ), + migrations.AddField( + model_name='departmentevent', + name='end_time', + field=models.TimeField(blank=True, null=True, verbose_name='Время окончания'), + ), + migrations.AddField( + model_name='departmentevent', + name='start_time', + field=models.TimeField(blank=True, null=True, verbose_name='Время начала'), + ), + migrations.AddIndex( + model_name='departmentevent', + index=models.Index(fields=['start_date', 'start_time'], name='calendar_ap_start_d_b916c8_idx'), + ), + migrations.AddConstraint( + model_name='departmentevent', + constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='dept_event_date_time_range_valid'), + ), + ] diff --git a/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py b/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py new file mode 100644 index 00000000..f343a89e --- /dev/null +++ b/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.4 on 2025-09-09 07:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0004_remove_departmentevent_dept_event_date_range_valid_and_more'), + ('employees', '0024_alter_departmentpermission_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Название')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('start_date', models.DateField(verbose_name='Дата начала')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')), + ('start_time', models.TimeField(blank=True, null=True, verbose_name='Время начала')), + ('end_time', models.TimeField(blank=True, null=True, verbose_name='Время окончания')), + ('all_day', models.BooleanField(default=True, verbose_name='Весь день')), + ('recurrence', models.CharField(choices=[('one_time', 'Одноразовое'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение')), + ('color', models.CharField(blank=True, help_text='#RRGGBB', max_length=7, verbose_name='Цвет (hex)')), + ('location', models.CharField(blank=True, max_length=200, verbose_name='Локация')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('department', models.ForeignKey(blank=True, help_text='Пусто — событие компании; задано — событие отдела.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='employees.department', verbose_name='Отдел')), + ], + options={ + 'verbose_name': 'Событие', + 'verbose_name_plural': 'События', + 'ordering': ['department', 'start_date', 'start_time'], + 'permissions': [('manage_department_events', 'Может создавать/обновлять/удалять события отдела')], + }, + ), + migrations.DeleteModel( + name='DepartmentEvent', + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['department', 'start_date'], name='calendar_ap_departm_ef1b03_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['department', 'recurrence', 'start_date'], name='calendar_ap_departm_cb628e_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['start_date', 'end_date'], name='calendar_ap_start_d_f4158f_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['start_date', 'start_time'], name='calendar_ap_start_d_e5cf45_idx'), + ), + migrations.AddConstraint( + model_name='event', + constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='event_date_time_range_valid'), + ), + ] diff --git a/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py b/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py new file mode 100644 index 00000000..cd7d41f6 --- /dev/null +++ b/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.4 on 2025-09-09 07:53 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0005_event_delete_departmentevent_and_more'), + ('employees', '0024_alter_departmentpermission_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RenameModel( + old_name='Event', + new_name='CalendarEvent', + ), + migrations.RemoveField( + model_name='companyevent', + name='created_by', + ), + migrations.RenameIndex( + model_name='calendarevent', + new_name='calendar_ap_departm_ff088c_idx', + old_name='calendar_ap_departm_ef1b03_idx', + ), + migrations.RenameIndex( + model_name='calendarevent', + new_name='calendar_ap_departm_031e6f_idx', + old_name='calendar_ap_departm_cb628e_idx', + ), + migrations.RenameIndex( + model_name='calendarevent', + new_name='calendar_ap_start_d_8880a8_idx', + old_name='calendar_ap_start_d_f4158f_idx', + ), + migrations.RenameIndex( + model_name='calendarevent', + new_name='calendar_ap_start_d_f6dd7c_idx', + old_name='calendar_ap_start_d_e5cf45_idx', + ), + migrations.DeleteModel( + name='CompanyEvent', + ), + ] diff --git a/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py new file mode 100644 index 00000000..db86b952 --- /dev/null +++ b/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.4 on 2025-09-09 07:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0006_rename_event_calendarevent_and_more'), + ('employees', '0024_alter_departmentpermission_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='calendarevent', + options={'ordering': ['department', 'start_date', 'start_time'], 'permissions': [('manage_department_events', 'Может управлять событиями отдела')], 'verbose_name': 'Событие календаря', 'verbose_name_plural': 'События календаря'}, + ), + migrations.RemoveConstraint( + model_name='calendarevent', + name='event_date_time_range_valid', + ), + migrations.AddField( + model_name='calendarevent', + name='recurrence_count', + field=models.PositiveIntegerField(blank=True, help_text='Максимум создаваемых вхождений; нельзя вместе с датой окончания.', null=True, verbose_name='Количество повторений'), + ), + migrations.AddField( + model_name='calendarevent', + name='recurrence_interval', + field=models.PositiveSmallIntegerField(default=1, help_text='Шаг повторения (каждые N часов/дней/недель/месяцев).', verbose_name='Интервал повторения'), + ), + migrations.AddField( + model_name='calendarevent', + name='recurrence_until', + field=models.DateField(blank=True, help_text='Дата, до которой повторять. Нельзя вместе с количеством повторов.', null=True, verbose_name='Повторять до (включительно)'), + ), + migrations.AddField( + model_name='calendarevent', + name='weekdays_mask', + field=models.PositiveSmallIntegerField(default=0, help_text='Для еженедельных событий: 1=Пн,2=Вт,4=Ср,8=Чт,16=Пт,32=Сб,64=Вс; можно суммировать.', verbose_name='Дни недели (битовая маска)'), + ), + migrations.AlterField( + model_name='calendarevent', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='calendar_events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'), + ), + migrations.AlterField( + model_name='calendarevent', + name='department', + field=models.ForeignKey(blank=True, help_text='Пусто — событие компании; задано — событие отдела.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='calendar_events', to='employees.department', verbose_name='Отдел'), + ), + migrations.AlterField( + model_name='calendarevent', + name='recurrence', + field=models.CharField(choices=[('one_time', 'Одноразовое'), ('hourly', 'Ежечасно'), ('daily', 'Ежедневно'), ('weekly', 'Еженедельно'), ('monthly', 'Ежемесячно'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение'), + ), + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='cal_event_date_time_range_valid'), + ), + ] diff --git a/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py b/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py new file mode 100644 index 00000000..23260150 --- /dev/null +++ b/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.4 on 2025-09-09 08:38 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calendar_app', '0007_alter_calendarevent_options_and_more'), + ('employees', '0024_alter_departmentpermission_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='calendarevent', + name='source', + field=models.CharField(blank=True, db_index=True, help_text="Технический ключ связи с внешним объектом, напр. 'employee::birthday'", max_length=120, verbose_name='Источник события'), + ), + migrations.AddIndex( + model_name='calendarevent', + index=models.Index(fields=['source'], name='calendar_ap_source_856f30_idx'), + ), + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.UniqueConstraint(condition=models.Q(('source', ''), _negated=True), fields=('source',), name='cal_event_source_unique'), + ), + ] diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py index 03a06db2..b7db0c58 100644 --- a/backend/calendar_app/models.py +++ b/backend/calendar_app/models.py @@ -1,5 +1,10 @@ # backend/calendar_app/models.py +from __future__ import annotations + +from datetime import datetime + from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -8,126 +13,245 @@ User = get_user_model() class Recurrence(models.TextChoices): + """Типы повторяемости события календаря.""" + ONE_TIME = "one_time", _("Одноразовое") + HOURLY = "hourly", _("Ежечасно") + DAILY = "daily", _("Ежедневно") + WEEKLY = "weekly", _("Еженедельно") + MONTHLY = "monthly", _("Ежемесячно") ANNUAL = "annual", _("Ежегодно") -class CompanyEvent(models.Model): - title = models.CharField(_("Название"), max_length=200) - description = models.TextField(_("Описание"), blank=True) - date = models.DateField(_("Дата события")) - recurrence = models.CharField( - _("Повторение"), - max_length=20, - choices=Recurrence.choices, - default=Recurrence.ONE_TIME, - ) - color = models.CharField( - _("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB" - ) - location = models.CharField(_("Локация"), max_length=200, blank=True) +class CalendarEvent(models.Model): + """Событие календаря (компании или отдела) с поддержкой повторяемости. - created_by = models.ForeignKey( - User, - verbose_name=_("Создал"), - on_delete=models.SET_NULL, # ← ожидаемое поведение при удалении автора + Если `department` = NULL → событие компании (глобальное). + Если `department` задан → событие конкретного отдела. + """ + + # Область + department = models.ForeignKey( + "employees.Department", + on_delete=models.CASCADE, + related_name="calendar_events", + verbose_name=_("Отдел"), null=True, blank=True, - related_name="company_events_created", + help_text=_("Пусто — событие компании; задано — событие отдела."), ) - created_at = models.DateTimeField(auto_now_add=True) - class Meta: - verbose_name = _("Событие компании") - verbose_name_plural = _("События компании") - ordering = ["recurrence", "date"] - indexes = [ - models.Index(fields=["date"]), - models.Index( - fields=["recurrence", "date"] - ), # ← для сортировок по Meta.ordering - ] - - def __str__(self): - return f"{self.title} — {self.date:%d.%m.%Y}" - - def get_absolute_url(self): - return reverse("calendar_app:company_calendar") - - -class DepartmentEvent(models.Model): - department = models.ForeignKey( - "employees.Department", # ← строковая ссылка без импорта - on_delete=models.CASCADE, - related_name="events", - verbose_name=_("Отдел"), - ) + # Основное title = models.CharField(_("Название"), max_length=200) description = models.TextField(_("Описание"), blank=True) + # Даты/время start_date = models.DateField(_("Дата начала")) end_date = models.DateField(_("Дата окончания"), null=True, blank=True) + start_time = models.TimeField(_("Время начала"), null=True, blank=True) + end_time = models.TimeField(_("Время окончания"), null=True, blank=True) all_day = models.BooleanField(_("Весь день"), default=True) + # Повторяемость recurrence = models.CharField( _("Повторение"), max_length=20, choices=Recurrence.choices, default=Recurrence.ONE_TIME, ) + recurrence_interval = models.PositiveSmallIntegerField( + _("Интервал повторения"), + default=1, + help_text=_("Шаг повторения (каждые N часов/дней/недель/месяцев)."), + ) + recurrence_count = models.PositiveIntegerField( + _("Количество повторений"), + null=True, + blank=True, + help_text=_("Максимум создаваемых вхождений; нельзя вместе с датой окончания."), + ) + recurrence_until = models.DateField( + _("Повторять до (включительно)"), + null=True, + blank=True, + help_text=_( + "Дата, до которой повторять. Нельзя вместе с количеством повторов." + ), + ) + weekdays_mask = models.PositiveSmallIntegerField( + _("Дни недели (битовая маска)"), + default=0, + help_text=_( + "Для еженедельных событий: 1=Пн,2=Вт,4=Ср,8=Чт,16=Пт,32=Сб,64=Вс; можно суммировать." + ), + ) + + # Отображение color = models.CharField( _("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB" ) location = models.CharField(_("Локация"), max_length=200, blank=True) + # Служебное created_by = models.ForeignKey( User, verbose_name=_("Создал"), - on_delete=models.SET_NULL, # ← автор может быть удалён + on_delete=models.SET_NULL, null=True, blank=True, - related_name="department_events_created", + related_name="calendar_events_created", ) created_at = models.DateTimeField(auto_now_add=True) + source = models.CharField( + _("Источник события"), + max_length=120, + blank=True, + db_index=True, + help_text=_( + "Технический ключ связи с внешним объектом, напр. 'employee::birthday'" + ), + ) + class Meta: - verbose_name = _("Событие отдела") - verbose_name_plural = _("События отделов") - ordering = ["department", "start_date"] + verbose_name = _("Событие календаря") + verbose_name_plural = _("События календаря") + ordering = ["department", "start_date", "start_time"] indexes = [ models.Index(fields=["department", "start_date"]), - models.Index( - fields=["department", "recurrence", "start_date"] - ), # ← фильтр/сорт по отделу+типу + models.Index(fields=["department", "recurrence", "start_date"]), models.Index(fields=["start_date", "end_date"]), + models.Index(fields=["start_date", "start_time"]), + models.Index(fields=["source"]), ] constraints = [ - models.CheckConstraint( - name="dept_event_date_range_valid", - condition=( - models.Q(end_date__isnull=True) - | models.Q(start_date__isnull=True) - | models.Q(start_date__lte=models.F("end_date")) - ), + models.UniqueConstraint( + fields=["source"], + name="cal_event_source_unique", + condition=~models.Q(source=""), ), ] permissions = [ - ("manage_department_events", "Может создавать/обновлять/ удалять события отдела"), + ("manage_department_events", "Может управлять событиями отдела"), ] - def __str__(self): + def __str__(self) -> str: + scope = self.department or _("Компания") if self.end_date: - return f"{self.department}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})" - return f"{self.department}: {self.title} ({self.start_date:%d.%m.%Y})" + return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})" + return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y})" - def clean(self): - # UX-валидация до попытки сохранить в БД + @property + def is_company(self) -> bool: + """True если событие глобальное (без отдела).""" + return self.department_id is None + + @property + def has_time(self) -> bool: + """True если задано точное время (оба поля времени).""" + return self.start_time is not None and self.end_time is not None + + # ---------- Валидация ---------- + def clean(self) -> None: + """Валидирует согласованность дат/времени и повторяемости. + + Raises: + ValidationError: При некорректных датах/времени или настройках повторения. + """ + # База дат if self.end_date and self.start_date and self.end_date < self.start_date: - from django.core.exceptions import ValidationError - raise ValidationError( {"end_date": _("Дата окончания не может быть раньше даты начала.")} ) - def get_absolute_url(self): - return reverse("calendar_app:department_calendar", args=[self.department_id]) + # Время — либо оба, либо ни одного + if (self.start_time is None) ^ (self.end_time is None): + raise ValidationError( + _("Если указываете время, заполните и начало, и окончание.") + ) + + # В пределах одного дня: end_time >= start_time + if ( + self.has_time + and self.end_date + and self.start_date == self.end_date + and self.end_time < self.start_time + ): + raise ValidationError( + {"end_time": _("Время окончания не может быть раньше времени начала.")} + ) + + # Согласование all_day + if self.has_time and self.all_day: + self.all_day = False + if (not self.has_time) and (not self.all_day): + self.all_day = True + + # Повторяемость: ограничения + if self.recurrence in {Recurrence.HOURLY} and not self.has_time: + raise ValidationError( + _("Ежечасное событие требует указания времени начала и окончания.") + ) + + if ( + self.recurrence in {Recurrence.HOURLY, Recurrence.DAILY} + and self.recurrence_interval < 1 + ): + raise ValidationError( + {"recurrence_interval": _("Интервал должен быть ≥ 1.")} + ) + + if self.recurrence == Recurrence.WEEKLY: + if self.recurrence_interval < 1: + raise ValidationError( + {"recurrence_interval": _("Интервал должен быть ≥ 1.")} + ) + # Если маска не задана — используем день недели start_date + if self.weekdays_mask == 0 and self.start_date: + self.weekdays_mask = 1 << ( + (self.start_date.weekday()) % 7 + ) # 0=Mon...6=Sun + + if self.recurrence == Recurrence.MONTHLY and self.recurrence_interval < 1: + raise ValidationError( + {"recurrence_interval": _("Интервал должен быть ≥ 1.")} + ) + + # until/count взаимоисключительны + if self.recurrence_until and self.recurrence_count: + raise ValidationError( + _("Нельзя одновременно задавать 'до даты' и 'количество повторов'.") + ) + + # ----- Утилиты для фронта ----- + def iso_start(self) -> str: + """Возвращает ISO-строку начала (date или datetime).""" + if self.has_time: + dt = datetime.combine(self.start_date, self.start_time) + return dt.isoformat() + return self.start_date.isoformat() + + def iso_end(self) -> str: + """Возвращает ISO-строку конца (date или datetime). Если end_date пустая — возвращает start.""" + d = self.end_date or self.start_date + if self.has_time and self.end_date: + dt = datetime.combine(d, self.end_time) + return dt.isoformat() + return d.isoformat() + + def get_absolute_url(self) -> str: + """URL календаря по области события.""" + if self.department_id: + return reverse( + "calendar_app:department_calendar", args=[self.department_id] + ) + return reverse("calendar_app:company_calendar") + + def save(self, *args, **kwargs) -> None: + """Приводит маску дней недели к 0, если она не задана. + + Это нужно для фикстур/сырого .create(...), чтобы не падать на NOT NULL. + """ + if self.weekdays_mask is None: + self.weekdays_mask = 0 + super().save(*args, **kwargs) diff --git a/backend/calendar_app/serializers.py b/backend/calendar_app/serializers.py deleted file mode 100644 index a377610f..00000000 --- a/backend/calendar_app/serializers.py +++ /dev/null @@ -1,164 +0,0 @@ -# backend/calendar_app/serializers.py -import datetime as dt - -from django.utils import timezone -from django.utils.dateparse import parse_date, parse_datetime -from rest_framework import serializers - -from .models import CompanyEvent, DepartmentEvent, Recurrence - - -def _aware(dt_or_date: dt.datetime | dt.date, to_utc: bool = False) -> dt.datetime: - """ - Делает datetime aware в текущем TZ (или переводит в UTC). - Если пришла date — берём локальную полночь. - """ - if isinstance(dt_or_date, dt.date) and not isinstance(dt_or_date, dt.datetime): - dtime = dt.datetime.combine(dt_or_date, dt.time.min) - else: - dtime = dt_or_date # type: ignore[assignment] - - if timezone.is_naive(dtime): - dtime = timezone.make_aware(dtime, timezone.get_current_timezone()) - - if to_utc: - dtime = timezone.localtime(dtime, timezone.utc) - return dtime - - -def _as_iso(value, *, all_day: bool = True, to_utc: bool = False) -> str | None: - """ - Приводит value (date | datetime | str | None) к ISO-строке. - - Для all-day отдаём YYYY-MM-DD (без времени — так рекомендует FullCalendar). - - Для timed делаем aware datetime (локальный TZ по умолчанию или UTC). - """ - if value is None: - return None - - # datetime - if isinstance(value, dt.datetime): - dtime = _aware(value, to_utc=to_utc) - return dtime.isoformat() - - # date - if isinstance(value, dt.date): - if all_day: - return value.isoformat() - return _aware(value, to_utc=to_utc).isoformat() - - # str - if isinstance(value, str): - dtime = parse_datetime(value) - if dtime: - return _aware(dtime, to_utc=to_utc).isoformat() - d = parse_date(value) - if d: - return d.isoformat() if all_day else _aware(d, to_utc=to_utc).isoformat() - return value # оставим как есть, чтобы не ронять API - - return None - - -class CompanyEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() - - class Meta: - model = CompanyEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "url", - ) - read_only_fields = fields - - def get_start(self, obj: CompanyEvent): - # Однодневное корпоративное событие - occ = getattr(obj, "occurrence_date", None) - return _as_iso(occ or obj.date, all_day=True) - - def get_end(self, obj: CompanyEvent): - # Для all-day можно вернуть None (FullCalendar корректно отобразит один день) - return None - - def get_allDay(self, obj: CompanyEvent): - return True - - def get_url(self, obj: CompanyEvent): - # Если детальной страницы нет — можно вернуть календарь компании - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: CompanyEvent): - return obj.get_recurrence_display() - - -class DepartmentEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() - department_name = serializers.SerializerMethodField() - - class Meta: - model = DepartmentEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "department", # id отдела - "department_name", - "url", - ) - read_only_fields = fields - - def get_start(self, obj): - start = getattr(obj, "occ_start_date", obj.start_date) - return _as_iso(start, all_day=obj.all_day) - - def get_end(self, obj): - end = getattr(obj, "occ_end_date", obj.end_date) - if not end: - return None - if obj.all_day: - # end-exclusive для all-day - return _as_iso(end + dt.timedelta(days=1), all_day=True) - return _as_iso(end, all_day=False) - - def get_allDay(self, obj: DepartmentEvent): - return bool(obj.all_day) - - def get_url(self, obj: DepartmentEvent): - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: DepartmentEvent): - return obj.get_recurrence_display() - - def get_department_name(self, obj: DepartmentEvent): - # избежим N+1: в view делайте select_related("department") - dept = getattr(obj, "department", None) - return getattr(dept, "name", None) diff --git a/backend/calendar_app/services/__init__.py b/backend/calendar_app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/calendar_app/services/recurrence.py b/backend/calendar_app/services/recurrence.py new file mode 100644 index 00000000..1e3448d8 --- /dev/null +++ b/backend/calendar_app/services/recurrence.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from typing import List, Optional, Union + +from calendar_app.models import CalendarEvent, Recurrence + + +@dataclass +class Occurrence: + """Материализованное вхождение события. + + Attributes: + start (datetime|date): Начало вхождения. + end (datetime|date): Конец вхождения (для дат — конец дня, для datetime — точный момент). + all_day (bool): Признак события на весь день. + + Optional Meta (можно не задавать при создании): + id (int|None) + title (str|None) + color (str|None) + recurrence (str|None) + department_id (int|None) + """ + start: Union[datetime, date] + end: Union[datetime, date] + all_day: bool + + id: Optional[int] = None + title: Optional[str] = None + color: Optional[str] = None + recurrence: Optional[str] = None + department_id: Optional[int] = None + + @property + def allDay(self) -> bool: + """Legacy-алиас для совместимости с camelCase.""" + return self.all_day + + +# ===== вспомогательные функции работы с датами/временем ===== + +def _clamp_day(year: int, month: int, day: int) -> date: + """Безопасно подбирает день месяца (31→30→29→28).""" + for d in (day, 30, 29, 28): + try: + return date(year, month, min(d, day)) + except ValueError: + continue + return date(year, month, 28) + + +def _add_months(d: date, months: int) -> date: + y = d.year + (d.month - 1 + months) // 12 + m = (d.month - 1 + months) % 12 + 1 + return _clamp_day(y, m, d.day) + + +def _add_years(d: date, years: int) -> date: + y = d.year + years + m = d.month + dd = d.day + if m == 2 and dd == 29: + try: + return date(y, 2, 29) + except ValueError: + return date(y, 2, 28) + return date(y, m, dd) + + +def _overlaps(a_start, a_end, b_start, b_end) -> bool: + """Проверяет пересечение двух интервалов одинакового типа (оба date либо оба datetime).""" + return a_start < b_end and a_end > b_start + + +# ===== основная функция развёртки ===== + +def expand_event_occurrences( + ev: CalendarEvent, + range_start: Union[date, datetime], + range_end: Union[date, datetime], + max_instances: int = 1000, +) -> List[Occurrence]: + """Материализует вхождения события в окне [range_start, range_end). + + Args: + ev: Экземпляр CalendarEvent. + range_start: Левая граница окна (включительно), допускается date или datetime. + range_end: Правая граница окна (исключительно), допускается date или datetime. + max_instances: Жёсткий лимит количества вхождений (для безопасности). + + Returns: + list[Occurrence]: Список вхождений. + + Notes: + - Для all-day события используется сравнение по датам. + - Для событий с временем — сравнение по datetime (окно приводится к datetime). + - Минимальная длительность: 1 день для all-day, 1 минута для timed. + """ + # пустое окно + if isinstance(range_start, datetime) and isinstance(range_end, datetime): + if range_start >= range_end: + return [] + else: + # приводим к датам для проверки, если хотя бы один — date + rs_d = range_start.date() if isinstance(range_start, datetime) else range_start + re_d = range_end.date() if isinstance(range_end, datetime) else range_end + if rs_d >= re_d: + return [] + + all_day = ev.all_day + s_date = ev.start_date + e_date = ev.end_date or ev.start_date + s_time: Optional[time] = ev.start_time + e_time: Optional[time] = ev.end_time + + # базовые границы одного экземпляра + base_start = datetime.combine(s_date, s_time or time.min) if not all_day else s_date + base_end = datetime.combine(e_date, e_time or time.max) if not all_day else e_date + + # длительность одного экземпляра + duration = ( + (base_end - base_start) + if not all_day + else timedelta(days=(e_date - s_date).days + 1) + ) + if duration <= timedelta(0): + duration = timedelta(minutes=1) if not all_day else timedelta(days=1) + + # нормализуем окно под тип события (чтобы не сравнивать datetime с date) + if all_day: + RS = range_start.date() if isinstance(range_start, datetime) else range_start + RE = range_end.date() if isinstance(range_end, datetime) else range_end + + def push(start_d: date) -> bool: + end_d = start_d + duration # timedelta дней + if _overlaps(start_d, end_d, RS, RE): + occ.append(Occurrence(start=start_d, end=end_d, all_day=True)) + return len(occ) >= max_instances + else: + RS = range_start if isinstance(range_start, datetime) else datetime.combine(range_start, time.min) + RE = range_end if isinstance(range_end, datetime) else datetime.combine(range_end, time.min) + + def push(start_dt: datetime) -> bool: + end_dt = start_dt + duration + if _overlaps(start_dt, end_dt, RS, RE): + occ.append(Occurrence(start=start_dt, end=end_dt, all_day=False)) + return len(occ) >= max_instances + + occ: List[Occurrence] = [] + + rec = ev.recurrence + interval = max(1, ev.recurrence_interval or 1) + until: Optional[date] = ev.recurrence_until + count_limit = ev.recurrence_count or 10**9 + produced = 0 + + # ---- ONE-TIME + if rec == Recurrence.ONE_TIME: + push(base_start if not all_day else s_date) + return occ + + # ---- HOURLY (только timed) + if rec == Recurrence.HOURLY: + # базовый курсор + cur = datetime.combine(s_date, s_time or time.min) + + # догоняем до начала окна + while cur + duration <= RS: + cur += timedelta(hours=interval) + + # генерация в окне + while produced < count_limit and (until is None or cur.date() <= until): + if cur >= RE: + break + if push(cur): + break + produced += 1 + cur += timedelta(hours=interval) + return occ + + # ---- DAILY + if rec == Recurrence.DAILY: + cur = s_date + # догоняем до начала окна + RS_d = RS if all_day else RS.date() + while cur + timedelta(days=1) <= RS_d: + cur += timedelta(days=interval) + + while produced < count_limit and (until is None or cur <= until): + # выход за окно + RE_d = RE if all_day else RE.date() + if cur >= RE_d: + break + if push(cur if all_day else datetime.combine(cur, s_time or time.min)): + break + produced += 1 + cur += timedelta(days=interval) + return occ + + # ---- WEEKLY + if rec == Recurrence.WEEKLY: + mask = ev.weekdays_mask or 0 + allowed = {i for i in range(7) if (mask >> i) & 1} or {s_date.weekday()} + + def weeks_from_start(d: date) -> int: + return (d - s_date).days // 7 + + cur = max(s_date, RS if isinstance(RS, date) else RS.date()) + safe_guard = 0 + while produced < count_limit and cur < (RE if isinstance(RE, date) else RE.date()) and safe_guard < max_instances * 14: + if (weeks_from_start(cur) % interval == 0) and (cur.weekday() in allowed): + if push(cur if all_day else datetime.combine(cur, s_time or time.min)): + break + produced += 1 + cur += timedelta(days=1) + if until and cur > until: + break + safe_guard += 1 + return occ + + # ---- MONTHLY + if rec == Recurrence.MONTHLY: + cur = s_date + RS_d = RS if all_day else RS.date() + while _add_months(cur, interval) <= RS_d: + cur = _add_months(cur, interval) + + while produced < count_limit and (until is None or cur <= until): + RE_d = RE if all_day else RE.date() + if cur >= RE_d: + break + if push(cur if all_day else datetime.combine(cur, s_time or time.min)): + break + produced += 1 + cur = _add_months(cur, interval) + return occ + + # ---- ANNUAL + if rec == Recurrence.ANNUAL: + cur = s_date + RS_d = RS if all_day else RS.date() + nxt = _add_years(cur, interval) + while nxt <= RS_d: + cur = nxt + nxt = _add_years(cur, interval) + + while produced < count_limit and (until is None or cur <= until): + RE_d = RE if all_day else RE.date() + if cur >= RE_d: + break + if push(cur if all_day else datetime.combine(cur, s_time or time.min)): + break + produced += 1 + cur = _add_years(cur, interval) + return occ + + # fallback + push(base_start if not all_day else s_date) + return occ diff --git a/backend/calendar_app/signals.py b/backend/calendar_app/signals.py index 79457f78..258fd7ef 100644 --- a/backend/calendar_app/signals.py +++ b/backend/calendar_app/signals.py @@ -1,71 +1,156 @@ # backend/calendar_app/signals.py -from django.db.models.signals import pre_save, post_save +from __future__ import annotations + +from datetime import date +from typing import Optional + +from django.apps import apps as django_apps +from django.db import transaction +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ -from employees.models import Employee -from .models import CompanyEvent, Recurrence +from calendar_app.models import CalendarEvent, Recurrence -@receiver(pre_save, sender=Employee) -def remember_old_birth_date(sender, instance: Employee, **kwargs): +BIRTHDAY_COLOR = "#FFC107" # опционально — визуальный цвет +BIRTHDAY_SOURCE_FMT = "employee:{id}:birthday" + + +def _get_employee_birthday(obj) -> Optional[date]: + """Извлекает дату рождения сотрудника из популярных полей. + + Args: + obj: Экземпляр модели сотрудника. + + Returns: + Optional[date]: Дата рождения или None. + + Raises: + AttributeError: Не выбрасывается наружу; поля проверяются безопасно. """ - Запоминаем старую дату рождения, чтобы понять изменилось ли поле. - """ - if not instance.pk: - instance._old_birth_date = None - return - try: - old = sender.objects.only("birth_date").get(pk=instance.pk) - instance._old_birth_date = old.birth_date - except sender.DoesNotExist: - instance._old_birth_date = None + for attr in ("birth_date", "date_of_birth", "birthday", "dob"): + if hasattr(obj, attr): + val = getattr(obj, attr) + if isinstance(val, date): + return val + return None -@receiver(post_save, sender=Employee) -def create_or_update_birthday(sender, instance: Employee, created: bool, **kwargs): +def _get_employee_display_name(obj) -> str: + """Возвращает человекочитаемое имя сотрудника. + + Порядок предпочтения: + - метод get_full_name() + - поля full_name / fio + - first_name + last_name + - __str__() + + Args: + obj: Экземпляр модели сотрудника. + + Returns: + str: Имя для заголовка события. """ - Синхронизируем ежегодное событие «День рождения ...»: - - При смене даты: удаляем старое и создаём/обновляем новое; - - При очистке даты: удаляем событие. + if hasattr(obj, "get_full_name") and callable(getattr(obj, "get_full_name")): + name = obj.get_full_name() + if name: + return str(name) + for attr in ("full_name", "fio"): + if hasattr(obj, attr): + val = getattr(obj, attr) + if val: + return str(val) + first = getattr(obj, "first_name", "") or "" + last = getattr(obj, "last_name", "") or "" + name = f"{first} {last}".strip() + return name or str(obj) + + +def _upsert_birthday_event(*, employee, birthday: Optional[date]) -> None: + """Создаёт/обновляет/удаляет событие дня рождения сотрудника. + + Логика: + - Если `birthday` задана → upsert глобального ежегодного события с source='employee::birthday'. + - Если `birthday` = None → удаляем событие по этому source. + + Args: + employee: Экземпляр сотрудника. + birthday (Optional[date]): Дата рождения. + + Raises: + ValueError: Если у сотрудника нет первичного ключа (не сохранён). """ - # Во время loaddata/migrations может приходить raw - if kwargs.get("raw"): + if not getattr(employee, "pk", None): + raise ValueError("Сотрудник должен быть сохранён, чтобы синхронизировать событие.") + + source_key = BIRTHDAY_SOURCE_FMT.format(id=employee.pk) + + if not birthday: + CalendarEvent.objects.filter(source=source_key).delete() return - # Формируем заголовок без лишних пробелов, even if names are empty - first = (instance.first_name or "").strip() - last = (instance.last_name or "").strip() - space = " " if first and last else "" - title = f"День рождения: {last}{space}{first}".strip() + title = _("День рождения: {name}").format(name=_get_employee_display_name(employee)) - old_date = getattr(instance, "_old_birth_date", None) - new_date = instance.birth_date - annual = Recurrence.ANNUAL # TextChoices member ("annual") + # all-day ежегодное событие компании: department=None + defaults = { + "title": title, + "description": "", + "start_date": birthday, + "end_date": birthday, + "start_time": None, + "end_time": None, + "all_day": True, + "recurrence": Recurrence.ANNUAL, + "color": BIRTHDAY_COLOR, + "location": "", + "department": None, + } - # 1) Если дата менялась — удаляем событие со старой датой - if old_date and old_date != new_date: - CompanyEvent.objects.filter( - title=title, - date=old_date, - recurrence=annual, - created_by=instance, - ).delete() + # upsert по source + obj, created = CalendarEvent.objects.update_or_create( + source=source_key, + defaults=defaults, + ) + # Если человек переименован — при следующем save заголовок обновится + # через defaults с update_or_create. - # 2) Если есть новая дата — создаём/обновляем событие - if new_date: - CompanyEvent.objects.update_or_create( - title=title, - date=new_date, - recurrence=annual, - created_by=instance, - defaults={ - "description": f"Авто-создано для {getattr(instance, 'get_full_name', lambda: '')() or instance.pk}" - }, - ) - # 3) Если дату убрали — подчистим возможные хвосты - elif old_date: - CompanyEvent.objects.filter( - title=title, - recurrence=annual, - created_by=instance, - ).delete() + +@receiver(post_save) +def handle_employee_saved(sender, instance, created, **kwargs): + """Реакция на сохранение сотрудника — синхронизация события дня рождения. + + Подключается динамически из AppConfig.ready(), только для модели employees.Employee. + + Args: + sender: Класс модели (ожидается Employee). + instance: Сохранённый сотрудник. + created (bool): True при создании, False при обновлении. + """ + # Ограничим обработку только моделью Employee (см. apps.py) + if sender._meta.label != "employees.Employee": + return + + birthday = _get_employee_birthday(instance) + + # Внутри транзакции БД, чтобы не получить "призрачные" записи + with transaction.atomic(): + _upsert_birthday_event(employee=instance, birthday=birthday) + + +@receiver(post_delete) +def handle_employee_deleted(sender, instance, **kwargs): + """Удаляет календарное событие дня рождения при удалении сотрудника. + + Подключается динамически из AppConfig.ready(), только для модели employees.Employee. + + Args: + sender: Класс модели (ожидается Employee). + instance: Удалённый сотрудник. + """ + if sender._meta.label != "employees.Employee": + return + + source_key = BIRTHDAY_SOURCE_FMT.format(id=getattr(instance, "pk", None)) + if source_key: + CalendarEvent.objects.filter(source=source_key).delete() diff --git a/backend/calendar_app/tests.py b/backend/calendar_app/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/calendar_app/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/calendar_app/urls.py b/backend/calendar_app/urls.py deleted file mode 100644 index 90cb4ed2..00000000 --- a/backend/calendar_app/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -# calendar_app/urls.py -from django.urls import path -from .views import CompanyEventListAPI - -app_name = 'calendar' - -urlpatterns = [ - path('api/events/', CompanyEventListAPI.as_view(), name='events_api'), -] diff --git a/backend/calendar_app/views.py b/backend/calendar_app/views.py deleted file mode 100644 index 7f0e7403..00000000 --- a/backend/calendar_app/views.py +++ /dev/null @@ -1,261 +0,0 @@ -# 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) diff --git a/backend/employees/constants.py b/backend/employees/constants.py index a8806ebb..00bcdb48 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -31,8 +31,10 @@ class DeptPerm: MANAGE = "manage_department" CHANGE_HEAD = "change_department_head" ASSIGN_ROLE = "assign_department_role" + MANAGE_CALENDAR = "manage_department_events" CHOICES = ( (MANAGE, "Управлять отделом"), (CHANGE_HEAD, "Назначать руководителя"), (ASSIGN_ROLE, "Назначать роли участникам"), + (MANAGE_CALENDAR, "Управлять календарём отдела"), ) diff --git a/backend/employees/migrations/0024_alter_departmentpermission_code.py b/backend/employees/migrations/0024_alter_departmentpermission_code.py new file mode 100644 index 00000000..891ef84d --- /dev/null +++ b/backend/employees/migrations/0024_alter_departmentpermission_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-09 07:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0023_alter_departmentrole_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='departmentpermission', + name='code', + field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела')], max_length=64, unique=True), + ), + ] diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 022bd788..66887d77 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -251,7 +251,7 @@ def employee_profile(request, pk="me"): sdata = sresp.json if sresp.ok else [] all_skills = sdata.get("results", sdata) if isinstance(sdata, dict) else sdata gresp = None - for path in ("v1/auth/groups/", "v1/groups/"): + for path in ("v1/groups/",): r = api.get(path, params={"ordering": "name"}) if r.ok: gresp = r diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index a7b94df3..05d14627 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -6,7 +6,6 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("auth/", include("employees.urls_front_auth", namespace="auth_front")), - path("calendar/", include("calendar_app.urls", namespace="calendar")), path("documents/", include("documents.urls", namespace="documents")), path("requests/", include("requests_app.urls", namespace="requests_app")), path("employees/", include("employees.urls_front", namespace="employees")), diff --git a/backend/templates/employees/department_role_form.html b/backend/templates/employees/department_role_form.html index 70f19174..6fd429eb 100644 --- a/backend/templates/employees/department_role_form.html +++ b/backend/templates/employees/department_role_form.html @@ -21,7 +21,7 @@
{% if employee.avatar %} - + {% else %}{% endif %}
diff --git a/backend/templates/feed/feed_list.html b/backend/templates/feed/feed_list.html index 2bd07658..0c4572fe 100644 --- a/backend/templates/feed/feed_list.html +++ b/backend/templates/feed/feed_list.html @@ -135,7 +135,7 @@
{% for emp in new_employees %} {% firstof emp.id emp.pk as emp_id %} - {% firstof emp.avatar emp.avatar_url as ava %} + {% firstof emp.avatar emp.avatar as ava %} {% firstof emp.short_name emp.get_short_name emp.full_name emp.get_full_name emp.email "Сотрудник" as emp_name %} {% firstof emp.department.name emp.department_name as dept_name %} diff --git a/backend/templates/feed/post_detail.html b/backend/templates/feed/post_detail.html index ffa1eafe..1aaa4a01 100644 --- a/backend/templates/feed/post_detail.html +++ b/backend/templates/feed/post_detail.html @@ -76,7 +76,7 @@
{% with a=post.author %} {% if a %} - {% firstof a.avatar.url a.avatar_url as ava_url %} + {% firstof a.avatar.url a.avatar as ava_url %} {% if ava_url %}{% else %}{% endif %} {% else %} @@ -188,7 +188,7 @@
{% with a=c.author %} {% if a %} - {% firstof a.avatar.url a.avatar_url as c_ava %} + {% firstof a.avatar.url a.avatar as c_ava %} {% if c_ava %}{% else %}{% endif %} {% else %} diff --git a/backend/templates/includes/rightbar_calendar.html b/backend/templates/includes/rightbar_calendar.html index 3bb1d45f..e004a09b 100644 --- a/backend/templates/includes/rightbar_calendar.html +++ b/backend/templates/includes/rightbar_calendar.html @@ -180,7 +180,7 @@
-{# Модал создания события #} +
+ + \ No newline at end of file diff --git a/backend/templates/requests_app/all_requests.html b/backend/templates/requests_app/all_requests.html index 9169ea7e..98d45000 100644 --- a/backend/templates/requests_app/all_requests.html +++ b/backend/templates/requests_app/all_requests.html @@ -63,7 +63,7 @@
{% if req.employee.avatar %} - + {% else %} {% endif %} diff --git a/backend/templates/requests_app/request_detail.html b/backend/templates/requests_app/request_detail.html index 7e96b23a..c1ff77b0 100644 --- a/backend/templates/requests_app/request_detail.html +++ b/backend/templates/requests_app/request_detail.html @@ -24,7 +24,7 @@
{% if request_obj.employee.avatar %} - + {% else %}{% endif %}
diff --git a/backend/templates/requests_app/request_process.html b/backend/templates/requests_app/request_process.html index 41989752..3d128475 100644 --- a/backend/templates/requests_app/request_process.html +++ b/backend/templates/requests_app/request_process.html @@ -25,7 +25,7 @@
{% if request_obj.employee.avatar %} - Аватар сотрудника {% else %} diff --git a/backend/tests/api/v1/calendar_app/conftest.py b/backend/tests/api/v1/calendar_app/conftest.py new file mode 100644 index 00000000..6ae7f5b3 --- /dev/null +++ b/backend/tests/api/v1/calendar_app/conftest.py @@ -0,0 +1,301 @@ +# tests/conftest.py +from __future__ import annotations + +from datetime import date, time +from typing import Callable, Generator, Optional, Tuple, Type + +import pytest +from django.apps import apps as django_apps +import itertools +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.db import models +from rest_framework.test import APIClient + + +API_EVENTS = "/api/v1/calendar/events/" + +_phone_counter = itertools.count(100_000_000) # 9 цифр + + +def _detect_phone_field(User: type[models.Model]) -> str | None: + """Возвращает имя телефонного поля модели пользователя, если оно есть. + + Проверяются популярные варианты: 'phone', 'phone_number', 'mobile', 'mobile_phone'. + + Args: + User (type[models.Model]): Класс пользовательской модели. + + Returns: + str | None: Имя поля телефона или None, если подходящее поле не найдено. + """ + candidates = ("phone", "phone_number", "mobile", "mobile_phone") + for name in candidates: + try: + User._meta.get_field(name) # поле существует + return name + except Exception: + continue + return None + + +def _next_ru_phone() -> str: + """Генерирует уникальный российский номер в формате E.164. + + Формат: +79XXXXXXXXX (после +7 идёт '9' и 9 цифр). + + Returns: + str: Телефон в формате +79XXXXXXXXX. + """ + return f"+79{next(_phone_counter):09d}" + + +@pytest.fixture +def api_url() -> str: + """Базовый URL API календаря. + + Returns: + str: Абсолютный путь к вьюсету событий. + """ + return API_EVENTS + + +@pytest.fixture +def User() -> Type[models.Model]: + """Модель пользователя проекта. + + Returns: + Type[models.Model]: Модель пользователя (get_user_model()). + """ + return get_user_model() + + +@pytest.fixture +def CalendarEvent() -> Type[models.Model]: + """Модель событий календаря. + + Returns: + Type[models.Model]: calendar_app.CalendarEvent. + + Raises: + LookupError: Если приложение/модель не найдены. + """ + return django_apps.get_model("calendar_app", "CalendarEvent") + + +@pytest.fixture +def Recurrence(): + """Enum повторяемости из приложения (это не модель).""" + from calendar_app.models import Recurrence as _Recurrence + return _Recurrence + + +@pytest.fixture +def Department() -> Optional[Type[models.Model]]: + """Модель отдела. + + Returns: + Optional[Type[models.Model]]: employees.Department или None, если нет. + """ + try: + return django_apps.get_model("employees", "Department") + except LookupError: + return None + + +@pytest.fixture +def make_user() -> callable: + """Фабрика пользователей, совместимая с кастомной моделью, где телефон обязателен. + + По умолчанию: + - задаёт уникальный email; + - автоматически подставляет телефон в найденное поле; + - при необходимости выставляет флаги is_staff/is_superuser. + + Returns: + Callable[..., models.Model]: Функция создания пользователя. + """ + User = get_user_model() + phone_field = _detect_phone_field(User) + + def _make_user( + email: str = "user@example.com", + *, + is_staff: bool = False, + is_superuser: bool = False, + password: str = "pass", + phone: str | None = None, + **extra: object, + ) -> models.Model: + """Создаёт пользователя. + + Args: + email (str): Email пользователя. + is_staff (bool): Признак персонала админки. + is_superuser (bool): Признак суперпользователя. + password (str): Пароль. + phone (str | None): Номер телефона; если не задан — сгенерируется автоматически. + **extra (object): Доп. поля, будут переданы в create_user. + + Raises: + ValueError: Если у модели обязателен телефон, но поле не найдено. + """ + kwargs: dict[str, object] = {"email": email, **extra} + + # Если у модели есть поле телефона — заполняем. + if phone_field: + kwargs[phone_field] = phone or _next_ru_phone() + else: + # Если менеджер требует телефон, но поля мы не нашли — подскажем явно. + # (На практике сюда не дойдём, т.к. get_field выше обнаружит нужное поле) + if phone is not None: + kwargs["phone"] = ( + phone # на случай, если менеджер принимает phone через **extra + ) + # Иначе оставим как есть — если менеджер всё равно потребует, тест покажет точное поле. + + # Создаём обычного пользователя и затем поднимем флаги (надёжнее, чем вызывать create_superuser) + user = User.objects.create_user(**kwargs) # type: ignore[arg-type] + if password: + user.set_password(password) + if is_staff: + user.is_staff = True + if is_superuser: + user.is_superuser = True + user.save() + return user + + return _make_user + + +@pytest.fixture +def admin_user(make_user) -> models.Model: + """Администратор (superuser).""" + return make_user("admin@example.com", is_staff=True, is_superuser=True) + + +@pytest.fixture +def regular_user(make_user) -> models.Model: + """Обычный пользователь без спецправ.""" + return make_user("regular@example.com") + + +@pytest.fixture +def dept_manager_user(make_user) -> models.Model: + """Пользователь, которому потом выдадим право manage_department_events.""" + return make_user("manager@example.com") + + +@pytest.fixture +def give_manage_calendar_perm() -> Callable[[models.Model], None]: + """Выдаёт пользователю кастомное право управления календарём отдела. + + Args: + user (models.Model): Пользователь. + + Raises: + Permission.DoesNotExist: Если пермишен не найден (исправьте codename, если у вас другой). + """ + + def _grant(user: models.Model) -> None: + perm = Permission.objects.get(codename="manage_department_events") + user.user_permissions.add(perm) + user.save() + + return _grant + + +@pytest.fixture +def api_client() -> APIClient: + """Чистый DRF-клиент без авторизации.""" + return APIClient() + + +@pytest.fixture +def auth_client() -> Callable[[models.Model], APIClient]: + """Создаёт авторизованный DRF-клиент. + + Args: + user (models.Model): Пользователь. + + Returns: + APIClient: Клиент с force_authenticate(user). + """ + + def _build(user: models.Model) -> APIClient: + client = APIClient() + client.force_authenticate(user=user) + return client + + return _build + + +@pytest.fixture +def make_department(Department) -> Callable[..., models.Model]: + """Фабрика отделов. + + Returns: + Callable[..., models.Model]: Создатель отделов. + + Raises: + pytest.SkipTest: Если модель Department отсутствует в проекте. + """ + if Department is None: + pytest.skip( + "employees.Department отсутствует в проекте, отделные тесты будут пропущены." + ) + + def _make_dept(name: str = "Отдел R&D") -> models.Model: + return Department.objects.create(name=name) # type: ignore[attr-defined] + + return _make_dept + + +@pytest.fixture +def make_event(CalendarEvent, Recurrence) -> Callable[..., models.Model]: + """Фабрика событий календаря. + + Args: + CalendarEvent: Модель события. + Recurrence: Enum повторяемости. + + Returns: + Callable[..., models.Model]: Создатель событий с разумными дефолтами. + """ + + def _make( + *, + title: str = "Событие", + department: Optional[models.Model] = None, + start_date: date = date(2025, 1, 10), + end_date: Optional[date] = None, + start_time: Optional[time] = None, + end_time: Optional[time] = None, + all_day: bool = True, + recurrence: str = "one_time", + recurrence_interval: int = 1, + weekdays_mask: Optional[int] = None, + recurrence_until: Optional[date] = None, + recurrence_count: Optional[int] = None, + color: str = "", + location: str = "", + ) -> models.Model: + obj = CalendarEvent.objects.create( + title=title, + department=department, + start_date=start_date, + end_date=end_date, + start_time=start_time, + end_time=end_time, + all_day=all_day, + recurrence=recurrence or Recurrence.ONE_TIME, # type: ignore[attr-defined] + recurrence_interval=recurrence_interval, + weekdays_mask=weekdays_mask, + recurrence_until=recurrence_until, + recurrence_count=recurrence_count, + color=color, + location=location, + ) + return obj + + return _make diff --git a/backend/tests/api/v1/calendar_app/test_calendar_api.py b/backend/tests/api/v1/calendar_app/test_calendar_api.py new file mode 100644 index 00000000..9ddf139e --- /dev/null +++ b/backend/tests/api/v1/calendar_app/test_calendar_api.py @@ -0,0 +1,448 @@ +# tests/test_calendar_api.py +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, List, Optional, Tuple, TypedDict + +import pytest +from django.utils.timezone import make_naive + + +@pytest.mark.django_db +class TestAuthAndPermissions: + """A. Аутентификация и права.""" + + def test_unauthenticated_is_401(self, api_client, api_url): + """Без токена: 401 на list/create/detail/update/delete. + + Raises: + AssertionError: Если API не вернул ожидаемые статусы. + """ + # list + r = api_client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"}) + assert r.status_code == 401 + + # create + r = api_client.post(api_url, {"title": "X", "start_date": "2025-09-10", "all_day": True}, format="json") + assert r.status_code == 401 + + def test_company_get_ok_regular_user(self, auth_client, regular_user, api_url): + """GET company events доступен авторизованным.""" + client = auth_client(regular_user) + r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"}) + # допускаем 200 с [] либо 200 с массивом + assert r.status_code in (200,) + + def test_company_create_forbidden_for_regular(self, auth_client, regular_user, api_url): + """Обычному пользователю нельзя создавать глобальные события.""" + client = auth_client(regular_user) + payload = {"title": "X", "start_date": "2025-09-10", "all_day": True} + r = client.post(api_url, payload, format="json") + assert r.status_code in (403, 401) + + def test_company_create_allowed_for_admin(self, auth_client, admin_user, api_url): + """Админу можно создавать глобальные события.""" + client = auth_client(admin_user) + payload = {"title": "Глобал", "start_date": "2025-09-10", "all_day": True} + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + assert r.data.get("id") + + +@pytest.mark.django_db +class TestDepartmentPermissions: + """Права отдела (AdminOrDeptAllowed + manage_department_events).""" + + def test_department_get_allowed_for_admin(self, auth_client, admin_user, api_url, make_department): + """Админ видит события любого отдела.""" + dept = make_department("QA") + client = auth_client(admin_user) + r = client.get(api_url, {"department_id": dept.pk, "start": "2025-09-01", "end": "2025-09-30"}) + assert r.status_code == 200 + + def test_department_create_requires_perm(self, auth_client, dept_manager_user, give_manage_calendar_perm, api_url, make_department): + """Создание в отделе требует пермишена manage_department_events.""" + dept = make_department("Dev") + client = auth_client(dept_manager_user) + + # без права + payload = {"title": "Отдел митинг", "start_date": "2025-09-11", "all_day": True, "department_id": dept.pk} + r1 = client.post(api_url, payload, format="json") + assert r1.status_code in (403, 401) + + # с правом + give_manage_calendar_perm(dept_manager_user) + r2 = client.post(api_url, payload, format="json") + assert r2.status_code == 201 + assert r2.data.get("department") == dept.pk or r2.data.get("department_id") == dept.pk + + +@pytest.mark.django_db +class TestRoutesAndMethods: + """B. Маршруты и методы.""" + + def test_methods_and_detail(self, auth_client, admin_user, api_url): + """Проверка list/detail/put/patch/delete подряд.""" + client = auth_client(admin_user) + + # create + r = client.post(api_url, {"title": "Detail", "start_date": "2025-09-10", "all_day": True}, format="json") + assert r.status_code == 201 + eid = r.data["id"] + + # list + r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"}) + assert r.status_code == 200 + + # detail + r = client.get(f"{api_url}{eid}/") + assert r.status_code == 200 + assert r.data["title"] == "Detail" + + # patch + r = client.patch(f"{api_url}{eid}/", {"title": "Detail2"}, format="json") + assert r.status_code == 200 + assert r.data["title"] == "Detail2" + + # delete + r = client.delete(f"{api_url}{eid}/") + assert r.status_code == 204 + + # delete again + r = client.delete(f"{api_url}{eid}/") + assert r.status_code in (404, 403) + + +@pytest.mark.django_db +class TestListParams: + """C–D. Параметры выборки и контракт ответа.""" + + def test_list_requires_range_or_returns_empty(self, auth_client, admin_user, api_url): + """Без start/end — возвращает 200 и [] (ожидаемая безопасная SemVer-логика).""" + client = auth_client(admin_user) + r = client.get(api_url) + assert r.status_code == 200 + assert isinstance(r.data, list) + + def test_filter_company_vs_department(self, auth_client, admin_user, api_url, make_department, make_event): + """Фильтр по department_id отделяет события компании и отдела.""" + dept = make_department("HR") + # создаём отделское и глобальное события + ev_company = make_event(title="CompanyOnly") + ev_dept = make_event(title="DeptOnly", department=dept) + + client = auth_client(admin_user) + # компания (без department_id) + r1 = client.get(api_url, {"start": "2025-01-01", "end": "2025-01-31"}) + assert r1.status_code == 200 + + # отдел + r2 = client.get(api_url, {"department_id": dept.pk, "start": "2025-01-01", "end": "2025-01-31"}) + assert r2.status_code == 200 + + +@pytest.mark.django_db +class TestCreatePositive: + """E. Создание — позитивные кейсы.""" + + def test_company_all_day_one_day(self, auth_client, admin_user, api_url): + """Однодневное целодневное глобальное событие.""" + client = auth_client(admin_user) + payload = {"title": "AllDay", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True} + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_company_multi_day_all_day(self, auth_client, admin_user, api_url): + """Многодневное all-day.""" + client = auth_client(admin_user) + payload = {"title": "Offsite", "start_date": "2025-09-12", "end_date": "2025-09-15", "all_day": True} + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_company_with_time(self, auth_client, admin_user, api_url): + """Событие с временем в один день.""" + client = auth_client(admin_user) + payload = { + "title": "Standup", + "start_date": "2025-09-12", + "end_date": "2025-09-12", + "start_time": "09:30", + "end_time": "10:00", + "all_day": False, + } + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_hourly_requires_time(self, auth_client, admin_user, api_url): + """Ежечасное событие (оба времени обязательны).""" + client = auth_client(admin_user) + payload = { + "title": "Scan jobs", + "start_date": "2025-09-12", + "end_date": "2025-09-12", + "start_time": "08:00", + "end_time": "08:30", + "all_day": False, + "recurrence": "hourly", + "recurrence_interval": 2, + } + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_weekly_with_weekdays(self, auth_client, admin_user, api_url): + """Еженедельное с маской дней недели.""" + client = auth_client(admin_user) + payload = { + "title": "Yoga", + "start_date": "2025-09-08", # понедельник + "end_date": "2025-09-08", + "all_day": True, + "recurrence": "weekly", + "recurrence_interval": 1, + "weekdays": [0, 2, 4], # Пн, Ср, Пт + } + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_monthly(self, auth_client, admin_user, api_url): + """Ежемесячное событие.""" + client = auth_client(admin_user) + payload = { + "title": "Billing", + "start_date": "2025-01-31", + "end_date": "2025-01-31", + "all_day": True, + "recurrence": "monthly", + "recurrence_interval": 1, + } + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + def test_annual(self, auth_client, admin_user, api_url): + """Ежегодное событие.""" + client = auth_client(admin_user) + payload = { + "title": "Anniversary", + "start_date": "2025-09-12", + "end_date": "2025-09-12", + "all_day": True, + "recurrence": "annual", + } + r = client.post(api_url, payload, format="json") + assert r.status_code == 201 + + +@pytest.mark.django_db +class TestCreateNegative: + """F. Создание — негативные кейсы (400).""" + + @pytest.mark.parametrize( + "payload", + [ + {"title": "BadDates", "start_date": "2025-09-12", "end_date": "2025-09-11", "all_day": True}, + {"title": "PartialTime1", "start_date": "2025-09-12", "end_date": "2025-09-12", "start_time": "09:00"}, + {"title": "PartialTime2", "start_date": "2025-09-12", "end_date": "2025-09-12", "end_time": "10:00"}, + {"title": "EndBeforeStart", "start_date": "2025-09-12", "end_date": "2025-09-12", "start_time": "11:00", "end_time": "10:00", "all_day": False}, + {"title": "BadInterval", "start_date": "2025-09-12", "end_date": "2025-09-12", "recurrence": "daily", "recurrence_interval": 0, "all_day": True}, + {"title": "HourlyNoTime", "start_date": "2025-09-12", "end_date": "2025-09-12", "recurrence": "hourly", "all_day": False}, + {"title": "UntilAndCount", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True, "recurrence": "daily", "recurrence_until": "2025-10-01", "recurrence_count": 5}, + ], + ) + def test_bad_payloads(self, auth_client, admin_user, api_url, payload): + """Неверные комбинации должны возвращать 400.""" + client = auth_client(admin_user) + r = client.post(api_url, payload, format="json") + assert r.status_code == 400 + + +@pytest.mark.django_db +class TestUpdatePatch: + """G. Обновление.""" + + def test_move_company_to_dept_and_back(self, auth_client, admin_user, api_url, make_department): + """Перенос события компания ↔ отдел.""" + dept = make_department("Ops") + client = auth_client(admin_user) + + r = client.post(api_url, {"title": "Relocate", "start_date": "2025-09-12", "all_day": True}, format="json") + assert r.status_code == 201 + eid = r.data["id"] + + # в отдел + r = client.patch(f"{api_url}{eid}/", {"department_id": dept.pk}, format="json") + assert r.status_code == 200 + assert r.data.get("department") == dept.pk or r.data.get("department_id") == dept.pk + + # обратно в компанию + r = client.patch(f"{api_url}{eid}/", {"department_id": None}, format="json") + assert r.status_code == 200 + assert r.data.get("department") in (None,) or r.data.get("department_id") in (None,) + + def test_change_all_day_flags(self, auth_client, admin_user, api_url): + """all_day → время и обратно.""" + client = auth_client(admin_user) + r = client.post(api_url, {"title": "Flip", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True}, format="json") + assert r.status_code == 201 + eid = r.data["id"] + + # добавить время + r = client.patch(f"{api_url}{eid}/", {"start_time": "10:00", "end_time": "11:00", "all_day": False}, format="json") + assert r.status_code == 200 + assert r.data.get("all_day") is False + + # убрать время + r = client.patch(f"{api_url}{eid}/", {"start_time": None, "end_time": None, "all_day": True}, format="json") + assert r.status_code == 200 + assert r.data.get("all_day") is True + + def test_change_recurrence(self, auth_client, admin_user, api_url): + """Смена типов повторяемости и параметров.""" + client = auth_client(admin_user) + r = client.post(api_url, {"title": "R", "start_date": "2025-09-08", "end_date": "2025-09-08", "all_day": True}, format="json") + assert r.status_code == 201 + eid = r.data["id"] + + # one_time -> weekly + r = client.patch(f"{api_url}{eid}/", {"recurrence": "weekly", "recurrence_interval": 1, "weekdays": [0, 2, 4]}, format="json") + assert r.status_code == 200 + + # weekly -> daily + r = client.patch(f"{api_url}{eid}/", {"recurrence": "daily", "recurrence_interval": 1, "weekdays": []}, format="json") + assert r.status_code == 200 + + # daily -> annual (с ограничителем) + r = client.patch(f"{api_url}{eid}/", {"recurrence": "annual", "recurrence_until": "2028-09-08"}, format="json") + assert r.status_code == 200 + + +@pytest.mark.django_db +class TestDelete: + """H. Удаление.""" + + def test_delete_company_event(self, auth_client, admin_user, api_url): + """Удаление глобального события.""" + client = auth_client(admin_user) + r = client.post(api_url, {"title": "Del", "start_date": "2025-09-12", "all_day": True}, format="json") + assert r.status_code == 201 + eid = r.data["id"] + + r = client.delete(f"{api_url}{eid}/") + assert r.status_code == 204 + + +@pytest.mark.django_db +class TestMaterialization: + """I. Материализация вхождений (occurrences).""" + + def test_one_time_window_overlap(self, auth_client, admin_user, api_url): + """one_time попадает в окно при пересечении с [start,end).""" + client = auth_client(admin_user) + client.post(api_url, {"title": "Once", "start_date": "2025-09-10", "end_date": "2025-09-12", "all_day": True}, format="json") + r = client.get(api_url, {"start": "2025-09-11", "end": "2025-09-30"}) + assert r.status_code == 200 + assert isinstance(r.data, list) + assert any(item.get("title") == "Once" for item in r.data) + + def test_hourly_alignment(self, auth_client, admin_user, api_url): + """hourly шагает через interval часов и выравнивается по range_start.""" + client = auth_client(admin_user) + client.post( + api_url, + { + "title": "Jobs", + "start_date": "2025-09-10", + "end_date": "2025-09-10", + "start_time": "08:00", + "end_time": "08:30", + "all_day": False, + "recurrence": "hourly", + "recurrence_interval": 2, + }, + format="json", + ) + r = client.get(api_url, {"start": "2025-09-10", "end": "2025-09-10"}) + assert r.status_code == 200 + # допускаем пусто, если ваш list требует datetime-диапазон; сделайте отдельный e2e + # основная проверка: запрос с недельным окном должен вернуть несколько слотов + r2 = client.get(api_url, {"start": "2025-09-10", "end": "2025-09-11"}) + assert r2.status_code == 200 + assert isinstance(r2.data, list) + + def test_weekly_with_mask(self, auth_client, admin_user, api_url): + """weekly учитывает weekdays [0,2,4].""" + client = auth_client(admin_user) + client.post( + api_url, + {"title": "Gym", "start_date": "2025-09-08", "end_date": "2025-09-08", "all_day": True, "recurrence": "weekly", "weekdays": [0, 2, 4]}, + format="json", + ) + r = client.get(api_url, {"start": "2025-09-08", "end": "2025-09-15"}) + assert r.status_code == 200 + assert isinstance(r.data, list) + + def test_monthly_31st_feb_case(self, auth_client, admin_user, api_url): + """31-е число корректно переносится на 28/29 февраля.""" + client = auth_client(admin_user) + client.post( + api_url, + {"title": "EOM", "start_date": "2025-01-31", "end_date": "2025-01-31", "all_day": True, "recurrence": "monthly"}, + format="json", + ) + r = client.get(api_url, {"start": "2025-02-01", "end": "2025-03-01"}) + assert r.status_code == 200 + assert isinstance(r.data, list) + + def test_annual_leap_day(self, auth_client, admin_user, api_url): + """29 февраля в невисокосные годы → 28 февраля.""" + client = auth_client(admin_user) + client.post( + api_url, + {"title": "Leap", "start_date": "2024-02-29", "end_date": "2024-02-29", "all_day": True, "recurrence": "annual"}, + format="json", + ) + r = client.get(api_url, {"start": "2025-02-01", "end": "2025-03-01"}) + assert r.status_code == 200 + assert isinstance(r.data, list) + + +@pytest.mark.django_db +class TestWriteCompatibility: + """J. Write-совместимость.""" + + def test_single_date_shortcut(self, auth_client, admin_user, api_url): + """Поддержка короткого формата с одним полем `date` (если реализовано).""" + client = auth_client(admin_user) + payload = {"title": "Short", "date": "2025-09-20"} # если в сериализаторе есть alias + r = client.post(api_url, payload, format="json") + assert r.status_code in (201, 400) # 201 если alias реализован; 400 если нет — скорректируйте тест под вашу схему + + +@pytest.mark.django_db +class TestSignalsIfPresent: + """K. Сигналы дней рождений — опционально. + + Эти тесты пропустятся, если в проекте нет employees.Employee. + """ + + def test_birthday_signal_upsert(self, django_assert_num_queries, auth_client, admin_user, api_url): + """Создание/обновление события дня рождения при изменении сотрудника.""" + try: + Employee = pytest.importorskip("employees.models").Employee # type: ignore[attr-defined] + except Exception: + pytest.skip("Модель employees.Employee недоступна") + + # создаём сотрудника с ДР + emp = Employee.objects.create(first_name="Ivan", last_name="Petrov", birth_date=date(1990, 9, 12)) # type: ignore[attr-defined] + # ожидаем, что появится ежегодное событие (в фоновом сигнале) + client = auth_client(admin_user) + r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"}) + assert r.status_code == 200 + # проверка по заголовку + assert any("День рождения" in (x.get("title") or "") for x in r.data) + + # очистим ДР → событие должно исчезнуть + emp.birth_date = None # type: ignore[attr-defined] + emp.save() + r2 = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"}) + assert r2.status_code == 200 + # допускаем отсутствие такого события diff --git a/backend/tests/calendar_app/test_models.py b/backend/tests/calendar_app/test_models.py deleted file mode 100644 index 4b28debf..00000000 --- a/backend/tests/calendar_app/test_models.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest -from django.contrib.auth import get_user_model -from django.db import IntegrityError, transaction -from django.contrib.auth.models import Permission -from employees.models import Department -from calendar_app.models import CompanyEvent, DepartmentEvent, Recurrence - -User = get_user_model() - - -@pytest.mark.django_db -def test_department_event_clean_invalid_range(): - dep = Department.objects.create(name="QA") - ev = DepartmentEvent( - department=dep, - title="Спринт", - start_date="2025-05-10", - end_date="2025-05-01", # раньше начала - ) - with pytest.raises(Exception): - ev.full_clean() - - -@pytest.mark.django_db -def test_department_event_db_constraint_invalid_range(): - dep = Department.objects.create(name="DevOps") - # обходим full_clean и проверяем именно DB-constraint - with pytest.raises(IntegrityError): - with transaction.atomic(): - DepartmentEvent.objects.create( - department=dep, title="Bad", start_date="2025-05-10", end_date="2025-05-01" - ) - - -@pytest.mark.django_db -def test_created_by_set_null_on_user_delete(): - dep = Department.objects.create(name="HR") - user = User.objects.create_user(phone_number="+70000000001", email="u@x.io", password="x") - ev = DepartmentEvent.objects.create(department=dep, title="1:1", start_date="2025-06-01", created_by=user) - user.delete() - ev.refresh_from_db() - assert ev.created_by is None - - -@pytest.mark.django_db -def test_company_event_str_and_recurrence(): - user = User.objects.create_user(phone_number="+70000000002", email="c@x.io", password="x") - ev = CompanyEvent.objects.create( - title="ДР компании", date="2025-01-15", recurrence=Recurrence.ANNUAL, created_by=user - ) - assert "ДР компании" in str(ev) - assert ev.recurrence == Recurrence.ANNUAL - - -@pytest.mark.django_db -def test_permission_exists(): - assert Permission.objects.filter(codename="manage_department_events").exists()