**feat(calendar): единая модель/эндпоинт календаря, повторяемость, права, сигналы и фронт**

Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (hourly/daily/weekly/monthly/annual), материализацию вхождений в списке, навели порядок с правами отделов и обновили фронтовой шаблон. Покрыли API pytest-тестами и починили мелкие баги.

### Ключевые изменения

**Модель**

* Заменили `CompanyEvent` и `DepartmentEvent` на единую **`CalendarEvent`**:

  * `department` (nullable FK) — если задан, событие отдела; если `NULL`, событие компании.
  * Даты/время: `start_date`, `end_date`, `start_time`, `end_time`, `all_day`.
  * Повторяемость: `recurrence` ∈ {`one_time`, `hourly`, `daily`, `weekly`, `monthly`, `annual`}.
  * Параметры повторов: `recurrence_interval` (int>0), `recurrence_until` (date), `recurrence_count` (int), `weekdays_mask` (bitmask для weekly).
  * Индексы под выборки и сортировку; check-constraint `cal_event_date_time_range_valid` (валидность диапазонов даты/времени; для одного дня `start_time <= end_time` при наличии обоих).
  * Поля оформления: `title`, `description`, `color`, `location`, `created_by`, `created_at`.
* Правка по умолчаниям: `weekdays_mask` теперь с безопасным default=0 (исправляет `NOT NULL` в SQLite).

**Сервисы**

* Добавлен сервис `expand_event_occurrences(event, range_start, range_end, max_instances=1000)`:

  * Материализует все вхождения события в окне `[range_start, range_end)`.
  * Корректно обрабатывает: leap day (29 Feb), месяцы с «дырками» (31-е), еженедельные маски, ежечасные интервалы.
  * Выдаёт ровные границы дат/датавремени для FullCalendar.

**Сериализаторы**

* `CalendarEventWriteSerializer` — строгая валидация:

  * Для hourly требуется `start_time` и `end_time`.
  * Для weekly — пустой/непустой `weekdays` валидируется и мапится в `weekdays_mask`.
  * `recurrence_interval` > 0; взаимоисключение `recurrence_until` и `recurrence_count`.
* `CalendarEventSerializer` — read-вариант с плоскими полями для фронта.
* Доп. сериализатор вьюхи для списка не нужен — материализуем в контроллере (см. ниже).

**ViewSet / API**

* Новый единый роут: **`/api/v1/calendar/events/`** (`ModelViewSet`):

  * `list`: возвращает **материализованные** вхождения в окне `[start, end)` (query-параметры `YYYY-MM-DD`). Если передан `department_id` — фильтрует события отдела, иначе — компании.
  * `retrieve/create/update/partial_update/destroy`: операции над самим `CalendarEvent`.
  * `get_queryset`: для `list` фильтрует по department/company, для detail — полный QS.
  * При `create`: подставляет `department_id` либо из контекста/параметров, либо из body, проставляет `created_by`, чистит кеш.
* **Права**:

  * Используется комбинированный пермишен **`ManageCalendarPerm(AdminOrDeptAllowed)`**.

    * Для компании делегирует на `AdminOrActionOrModelPerms`.
    * Для отдела требует **строковый код**: `"manage_department_events"`.
    * Обновляет инстанс пользователя из БД (сбрасывает кэши), чтобы в одном тесте «выдали право → сразу работает».
  * `get_permissions()` → `[IsAuthenticated(), ManageCalendarPerm()]`.
* Починки:

  * Подключены `parse_date`/`status` где нужно.
  * Исправлен конфликт дат/датавремени в материализации.
  * Возврат полей в `list`: `start`, `end`, `allDay`, `color`, `recurrence`, `department_id` (ID родителя можно оставить как `event.id`).

**Signals**

* Добавлен сигнал на изменение `date_of_birth` сотрудника:

  * Создаёт/обновляет/удаляет соответствующее ежегодное событие в **глобальном** календаре.
  * Идентификация через `source="employee_birthday"` + `source_ref=<employee_id>`.

**Админка**

* `CalendarEventAdmin`:

  * Поиск по названию/описанию/локации.
  * Фильтры по отделу, типу повторения, all\_day, наличию времени, «company vs department».
  * Инлайнов нет; list-представление упрощено (исправлены ошибки `admin.E116` — фильтры теперь валидные `SimpleListFilter`/поля).

**Фронт (шаблон правой колонки)**

* Переписан `<script>` под единый эндпоинт:

  * Список: GET `/api/v1/calendar/events/?[department_id=...]&start=YYYY-MM-DD&end=YYYY-MM-DD`.
  * Создание:

    * Компания: POST `/api/v1/calendar/events/` с `{title, start_date, [end_date], all_day}`.
    * Отдел: POST туда же, но с `department_id` (в query или в body).
    * Если указано время — **оба** (`start_time`, `end_time`) обязательны; иначе сохраняем как all-day.
  * Нормализация дат в локали, аккуратные конвертации для FullCalendar.
  * Заголовки авторизации: `Authorization: Bearer <token>` из `<meta name="api-access">`/`localStorage.api.access`. Добавлен fallback на CSRF-куку при отсутствии токена (для SessionAuth).
  * Улучшен UI выбора отдела (`/api/v1/departments/my-departments/`), подписи, недельная лента.

**Тесты**

* Добавлены/актуализированы pytest-тесты API:

  * Роуты и методы (CRUD).
  * Позитив/негатив создания (валидация дат/времени/повторов).
  * Изменение повторяемости (weekly → daily и пр.).
  * Материализация (hourly alignment, weekly mask, monthly edge-cases, leap day).
  * Права: отдел без/с `manage_department_events` (фикс за счёт строкового кода).
  * Сигналы: апсерт/удаление дней рождения.
* Исправлены прежние падения: `NOT NULL weekdays_mask`, `NameError(parse_date/status)`, сравнения date/datetime, несоответствие `all_day` vs `allDay`, и др.

### Совместимость и заметки по деплою

* **BREAKING**: единый эндпоинт `/api/v1/calendar/events/` заменяет старые `/company-events/` и `/department-events/`. Если есть сторонние клиенты — переориентировать на новый.
* Миграции обязательны (новая таблица, индексы, дефолты).
* Если где-то ещё использовались константы прав — убедиться, что везде строка `"manage_department_events"` (тесты ориентируются на неё).
* Для фронта: убедиться, что реально прокидывается access-токен в `<meta name="api-access">` **или** есть валидная CSRF-кука при SessionAuth. Иначе POST вернёт 401.

### Почему так

* Единая модель/роут упрощают код и устраняют разъезд схем компании/отделов.
* Материализация в контроллере избавляет фронт от логики повторов и держит поведение единообразным.
* Явная строка кода права синхронизирована с тестовой выдачей пермишена и реальным хранилищем прав отделов.
* Валидация сериализатора закрывает большую часть негативных кейсов на уровне API.
This commit is contained in:
admin i 2025-09-09 17:55:15 +03:00
parent c5123eb02f
commit e30c059529
36 changed files with 2638 additions and 1150 deletions

Binary file not shown.

View File

@ -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)

View File

@ -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/<int:pk>/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
def perform_destroy(self, instance: CalendarEvent) -> None:
"""Удаляет событие и чистит кеш."""
instance.delete()
cache.clear()

View File

@ -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,
)

View File

@ -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.<perm_code>) — пускаем
# (сбросим кэш прав — в тестах перм выдают после первого запроса)
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",

View File

@ -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/<int:pk>/events/",
DepartmentEventsViewSet.as_view({"get": "list"}),
name="department-events",
),
path("", include(router.urls)),
]

View File

@ -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

View File

@ -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(
'<span style="display:inline-block;width:12px;height:12px;'
'border-radius:2px;border:1px solid #ccc;vertical-align:-2px;'
'background:{};margin-right:6px;"></span><code>{}</code>',
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)

View File

@ -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",
)

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

@ -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:<id>: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'),
),
]

View File

@ -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:<id>: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)

View File

@ -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)

View File

@ -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

View File

@ -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:<id>: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()

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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'),
]

View File

@ -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)

View File

@ -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, "Управлять календарём отдела"),
)

View File

@ -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),
),
]

View File

@ -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

View File

@ -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")),

View File

@ -21,7 +21,7 @@
<header class="feed-hd">
<div class="feed-ava" style="width:56px;height:56px;">
{% if employee.avatar %}
<img src="{{ employee.avatar_url }}" alt="">
<img src="{{ employee.avatar }}" alt="">
{% else %}<i class="bi-person"></i>{% endif %}
</div>
<div class="feed-meta">

View File

@ -135,7 +135,7 @@
<div class="join-rail">
{% 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 %}

View File

@ -76,7 +76,7 @@
<div class="feed-ava">
{% 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 %}<img src="{{ ava_url }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
{% else %}
<i class="bi-person"></i>
@ -188,7 +188,7 @@
<div class="feed-ava" style="width:36px;height:36px;">
{% 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 %}<img src="{{ c_ava }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
{% else %}
<i class="bi-person"></i>

View File

@ -180,7 +180,7 @@
</div>
</div>
{# Модал создания события #}
<!-- Модал создания события — ОБНОВЛЁННЫЙ -->
<div class="modal fade" id="eventCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="eventForm">
@ -188,6 +188,7 @@
<h5 class="modal-title">Новое событие</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">Заголовок</label>
@ -206,14 +207,89 @@
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" value="1" id="allDayChk" name="all_day">
<input class="form-check-input" type="checkbox" value="1" id="allDayChk" name="all_day" checked>
<label class="form-check-label" for="allDayChk">Весь день</label>
</div>
<hr class="my-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Повторение</label>
<select class="form-select" name="recurrence" id="recurrenceSelect">
<option value="one_time" selected>Одноразовое</option>
<option value="hourly">Ежечасно</option>
<option value="daily">Ежедневно</option>
<option value="weekly">Еженедельно</option>
<option value="monthly">Ежемесячно</option>
<option value="annual">Ежегодно</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Интервал</label>
<input type="number" class="form-control" name="recurrence_interval" id="recurrenceInterval" value="1" min="1" step="1">
<div class="form-text">Каждые N часов/дней/недель/месяцев</div>
</div>
</div>
<div class="mt-2 d-none" id="weeklyBlock">
<label class="form-label d-block mb-1">Дни недели</label>
<div class="d-flex flex-wrap gap-2">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="0"> Пн
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="1"> Вт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="2"> Ср
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="3"> Чт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="4"> Пт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="5"> Сб
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="6"> Вс
</label>
</div>
<div class="form-text">Если пусто — возьмём день недели даты начала.</div>
</div>
<div class="row g-2 mt-2">
<div class="col-6">
<label class="form-label">Повторять до</label>
<input type="date" class="form-control" name="recurrence_until" id="recurrenceUntil">
</div>
<div class="col-6">
<label class="form-label">Кол-во повторов</label>
<input type="number" class="form-control" name="recurrence_count" id="recurrenceCount" min="1" step="1">
<div class="form-text">Нельзя вместе с «Повторять до»</div>
</div>
</div>
<hr class="my-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Цвет</label>
<input type="text" class="form-control" name="color" placeholder="#RRGGBB">
</div>
<div class="col-6">
<label class="form-label">Локация</label>
<input type="text" class="form-control" name="location" placeholder="Переговорка, адрес…">
</div>
</div>
<small class="text-secondary d-block mt-2">
Календарь: <span id="eventTargetLabel">Компания</span>
</small>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Создать</button>
</div>
@ -221,10 +297,11 @@
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
if (!window.FullCalendar) return;
/* ===== токен из meta ===== */
function getAccessToken(){
const meta = document.querySelector('meta[name="api-access"]');
@ -237,34 +314,76 @@
return t ? { 'Authorization': 'Bearer ' + t } : {};
}
/* ===== endpoints ===== */
const API_COMPANY = '/api/v1/calendar/company-events/';
const API_MY_DEPTS = '/api/v1/departments/my-departments/'; // <-- новый эндпоинт
const holder = document.querySelector('.rightbar-card');
/* ===== endpoints (единый вьюсет) ===== */
const API_EVENTS = '/api/v1/calendar/events/';
const API_MY_DEPTS = '/api/v1/departments/my-departments/'; // список отделов пользователя
const holder = document.querySelector('.rightbar-card');
// оставим обратную совместимость: если в data-атрибуте переданы id отделов
const legacyDeptIds = (holder?.dataset.deptId || '').split(',').map(s=>s.trim()).filter(Boolean);
/* ===== только ЧИСЛОВОЙ PK отдела ===== */
const DIGITS_RE = /^\d+$/;
function extractNumericPk(d){
const cands = [d?.pk, d?.id, d?.department_id];
for (const v of cands){
const s = String(v ?? '').trim();
if (s && DIGITS_RE.test(s)) return s;
}
return null;
}
const deptUrl = (id) => `/api/v1/calendar/departments/${encodeURIComponent(id)}/events/`;
// YYYY-MM-DD в ЛОКАЛИ (без TZ-сдвига)
function ymdLocal(date){
const y = date.getFullYear();
const m = String(date.getMonth()+1).padStart(2,'0');
const d = String(date.getDate()).padStart(2,'0');
return `${y}-${m}-${d}`;
}
// базовый URL событий: для отдела добавляем department_id
const eventsUrl = (deptId=null) => {
const u = new URL(API_EVENTS, location.origin);
if (deptId != null) u.searchParams.set('department_id', String(deptId));
return u.pathname + (u.search ? ('?' + u.searchParams.toString()) : '');
};
// корректно вешаем диапазон дат на любой URL
function addRange(url, start, end){
const u = new URL(url, location.origin);
u.searchParams.set('start', ymdLocal(start));
u.searchParams.set('end', ymdLocal(end));
return u.pathname + '?' + u.searchParams.toString();
}
// обратная совместимость: data-dept-id="1,2,3"
const legacyDeptIds = (holder?.dataset.deptId || '')
.split(',').map(s=>s.trim()).filter(s => DIGITS_RE.test(s));
/* ===== элементы UI ===== */
const chooserBtn = document.getElementById('calendarChooserBtn');
const chooserMenu = document.getElementById('calendarChooserMenu');
const eventTargetLabel = document.getElementById('eventTargetLabel');
// поля модала для переключений
const allDayChk = document.getElementById('allDayChk');
const recurrenceSelect = document.getElementById('recurrenceSelect');
const weeklyBlock = document.getElementById('weeklyBlock');
const recurrenceInterval= document.getElementById('recurrenceInterval');
const recurrenceUntil = document.getElementById('recurrenceUntil');
const recurrenceCount = document.getElementById('recurrenceCount');
/* ===== состояние ===== */
let departments = []; // [{id, name}]
let departments = []; // [{id, name}] где id — всегда числовой PK
const state = { type: 'company', deptId: null };
/* ===== utils ===== */
const dayMs = 86400000, hourMs = 3600000;
const isDateOnly = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
const isDateOnly = v => typeof v ==='string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
const toDate = v => {
if (!v) return null;
if (v instanceof Date) return v;
if (typeof v === 'number') return new Date(v);
if (typeof v === 'string'){
const s=v.trim(); if (isDateOnly(s)){ const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); }
const s=v.trim();
if (isDateOnly(s)){ const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); }
const t=Date.parse(s); if (!isNaN(t)) return new Date(t);
}
return null;
@ -274,37 +393,41 @@
const endOfWeek = d => { const s=startOfWeek(d); const e=new Date(s); e.setDate(s.getDate()+7); return e; };
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
async function fetchJSON(url){
const r = await fetch(url, { headers: { 'Accept':'application/json', ...authHeaders() } });
async function fetchJSON(url, opts){
const r = await fetch(url, { headers: { 'Accept':'application/json', ...authHeaders(), ...(opts?.headers||{}) }, ...(opts||{}) });
if (r.status === 401) { console.warn('401 от API — нужен валидный access токен'); return []; }
if (!r.ok) { console.error('HTTP', r.status, url); return []; }
if (!r.ok) {
let text = await r.text();
try{ text = JSON.parse(text); }catch{}
throw { status: r.status, data: text };
}
const data = await r.json();
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
}
function qsRange(start, end){
const s = start.toISOString().slice(0,10);
const e = end.toISOString().slice(0,10);
return `?start=${s}&end=${e}`;
}
/* ===== загрузка отделов через /my-departments ===== */
/* ===== загрузка отделов (только числовой PK) ===== */
async function loadDepartments(){
// подчистим старые пункты отделов (если были)
chooserMenu.querySelectorAll('[data-cal="dept"]').forEach(n => n.closest('li')?.remove());
let raw = await fetchJSON(API_MY_DEPTS).catch(()=>[]);
if (!Array.isArray(raw) || !raw.length){
// fallback: построим из legacy id
departments = legacyDeptIds.map(id => ({ id, name: `Отдел #${id}` }));
} else {
departments = raw.map(d => ({
id: d.id ?? d.pk ?? d.uuid ?? d.slug,
name: d.name ?? d.title ?? d.code ?? `Отдел #${d.id ?? d.pk ?? ''}`
})).filter(d => d.id != null);
let raw = [];
try{ raw = await fetchJSON(API_MY_DEPTS); }catch(e){ raw = []; }
let mapped = (Array.isArray(raw) ? raw : [])
.map(d => {
const pk = extractNumericPk(d);
if (!pk) return null;
const name = d.name ?? d.title ?? d.code ?? `Отдел #${pk}`;
return { id: pk, name };
})
.filter(Boolean);
if (!mapped.length && legacyDeptIds.length){
mapped = legacyDeptIds.map(pk => ({ id: pk, name: `Отдел #${pk}` }));
}
departments = mapped;
if (!departments.length){
// если отделов нет — оставляем только "Компания"
setChooserLabel();
return;
}
@ -322,14 +445,10 @@
});
chooserMenu.appendChild(frag);
// если уже выбран отдел, но его нет в списке — вернёмся к "Компания"
if (state.type === 'dept' && !departments.some(d => String(d.id) === String(state.deptId))){
state.type = 'company'; state.deptId = null;
}
// если ничего не выбрано и хочется автоподставить первый отдел — раскомментируй:
// if (state.type === 'company' && departments.length) { state.type='dept'; state.deptId=departments[0].id; }
setChooserLabel();
}
@ -351,33 +470,38 @@
if (type === 'company'){
state.type='company'; state.deptId=null;
} else {
state.type='dept'; state.deptId=btn.dataset.id;
const id = btn.dataset.id;
if (!DIGITS_RE.test(String(id || ''))) { alert('Некорректный идентификатор отдела'); return; }
state.type='dept'; state.deptId=id;
}
setChooserLabel();
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
updateWeekLists(); // источники учтём внутри
updateWeekLists();
});
/* ===== комбинированная загрузка событий ===== */
/* ===== комбинированная загрузка событий (occurrences) ===== */
async function fetchEventsCombined(start, end){
const q = qsRange(start, end);
let urls = [];
if (state.type === 'company') urls = [API_COMPANY + q];
else if (state.type === 'dept' && state.deptId) urls = [deptUrl(state.deptId) + q];
if (!urls.length) return [];
const parts = await Promise.all(urls.map(u => fetchJSON(u).catch(()=>[])));
return parts.flat();
let base = eventsUrl(state.type === 'dept' ? state.deptId : null);
const withRange = addRange(base, start, end);
try{
return await fetchJSON(withRange);
}catch(e){
console.error('Fetch events failed', e);
return [];
}
}
function normalizeEvent(ev){
const title = pick(ev, ['title','name','summary','text']) || '';
const rs = pick(ev, ['start','start_date','date_start','date','date_from']);
const re = pick(ev, ['end','end_date','date_end','date_to']);
const allDay = ('allDay' in ev) ? !!ev.allDay : (isDateOnly(rs) && (!re || isDateOnly(re)));
const allDay = ('allDay' in ev) ? !!ev.allDay : (('all_day' in ev) ? !!ev.all_day : (isDateOnly(rs) && (!re || isDateOnly(re))));
const s = toDate(rs); let e = toDate(re);
if (!s) return null;
if (!e) e = new Date(s.getTime() + (allDay ? dayMs : hourMs));
return { title, start:s, end:e, allDay, color: ev.color || null, recurrence: ev.recurrence || null };
const color = ev.color || ev.bgColor || null;
const recurrence = ev.recurrence || ev.recurrence_display || null;
return { title, start:s, end:e, allDay, color, recurrence };
}
function renderVertical(container, rangeLabel, events, ws, we){
@ -491,59 +615,132 @@
setTimeout(()=> updateWeekLists(deskCalendar || mobCalendar), 100);
[deskCalendar, mobCalendar].forEach(cal => cal?.on('datesSet', ()=> updateWeekLists(cal)));
/* ===== UX: переключалки формы ===== */
function syncByRecurrence(){
const r = recurrenceSelect?.value || 'one_time';
// weekly block
if (r === 'weekly'){ weeklyBlock?.classList.remove('d-none'); } else { weeklyBlock?.classList.add('d-none'); }
// hourly требует времени → снимаем all-day и блокируем чекбокс
if (r === 'hourly'){
allDayChk.checked = false;
allDayChk.disabled = true;
} else {
allDayChk.disabled = false;
}
}
function syncByAllDay(){
const isAllDay = allDayChk?.checked;
const startEl = document.querySelector('input[name="start"]');
const endEl = document.querySelector('input[name="end"]');
if (!startEl || !endEl) return;
// Если all-day — время можно не указывать, но поля не блокируем (пусть пользователь вводит, тогда чек снимем на submit)
// Визуально оставим как есть.
}
function syncUntilCount(){
const hasUntil = !!recurrenceUntil?.value;
if (hasUntil){
recurrenceCount.value = '';
recurrenceCount.disabled = true;
} else {
recurrenceCount.disabled = false;
}
}
recurrenceSelect?.addEventListener('change', syncByRecurrence);
allDayChk?.addEventListener('change', syncByAllDay);
recurrenceUntil?.addEventListener('change', syncUntilCount);
syncByRecurrence(); syncByAllDay(); syncUntilCount();
/* ===== создание события (POST только с Bearer) ===== */
const form = document.getElementById('eventForm');
form?.addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(form);
const title = (fd.get('title')||'').toString().trim();
const allDay = !!fd.get('all_day');
let start = fd.get('start')?.toString() || '';
let end = fd.get('end')?.toString() || '';
const allDay = !!fd.get('all_day');
if (!title){
alert('Укажите заголовок'); return;
}
let url = '';
let payload = {};
if (state.type === 'company'){
url = API_COMPANY;
const date = (start || new Date().toISOString().slice(0,16)).slice(0,10);
payload = { title, date };
}else if (state.type === 'dept' && state.deptId){
url = deptUrl(state.deptId);
if (!start) start = new Date().toISOString().slice(0,16);
if (!end){
const dt = new Date(start); dt.setHours(dt.getHours()+1);
end = dt.toISOString().slice(0,16);
// Разбираем datetime-local → даты/время
const nowIso = new Date().toISOString().slice(0,16);
const sDate = (start || nowIso).slice(0,10);
const eDate = (end && end.trim() ? end : start || '').slice(0,10) || sDate;
const sTime = (start && start.length >= 16) ? start.slice(11,16) : '';
const eTime = (end && end.length >= 16) ? end.slice(11,16) : '';
// Повторяемость
const recurrence = (fd.get('recurrence') || 'one_time').toString();
const recurrence_interval = Math.max(1, parseInt(fd.get('recurrence_interval') || '1', 10));
const recurrence_until = (fd.get('recurrence_until') || '').toString().trim();
const recurrence_count_raw = (fd.get('recurrence_count') || '').toString().trim();
const recurrence_count = recurrence_count_raw ? Math.max(1, parseInt(recurrence_count_raw, 10)) : null;
// weekly weekdays → массив чисел
const weekdays = Array.from(document.querySelectorAll('input[name="weekdays"]:checked')).map(el => parseInt(el.value, 10));
// Базовый payload
const payload = {
title,
start_date: sDate,
end_date: eDate,
all_day: allDay,
recurrence,
recurrence_interval,
color: (fd.get('color') || '').toString().trim() || null,
location: (fd.get('location') || '').toString().trim() || '',
};
// Время (если all_day=false ИЛИ пользователь указал оба времени)
if (!allDay || (sTime && eTime)) {
if (sTime && eTime) {
payload.start_time = sTime; // "HH:MM"
payload.end_time = eTime; // "HH:MM"
payload.all_day = false;
} else if (recurrence === 'hourly') {
alert('Ежечасное событие требует и время начала, и время окончания.');
return;
}
payload = allDay
? { title, start: start.slice(0,10), end: end.slice(0,10), allDay: true }
: { title, start: new Date(start).toISOString(), end: new Date(end).toISOString(), allDay: false };
}else{
alert('Выберите календарь'); return;
}
// Ограничители серии
if (recurrence_until && recurrence_count){
alert('Нельзя одновременно задавать "Повторять до" и "Кол-во повторов". Укажите что-то одно.');
return;
}
if (recurrence_until) payload.recurrence_until = recurrence_until;
if (recurrence_count) payload.recurrence_count = recurrence_count;
// Weekly: передаём weekdays если выбраны
if (recurrence === 'weekly' && weekdays.length){
payload.weekdays = weekdays;
}
// Область — компания/отдел
if (state.type === 'dept') {
if (!DIGITS_RE.test(String(state.deptId))) { alert('Некорректный отдел'); return; }
payload.department_id = Number(state.deptId);
}
try{
const r = await fetch(url, {
await fetchJSON(API_EVENTS, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept':'application/json', ...authHeaders() },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (r.status === 401){ alert('Сессия API истекла. Перезайдите.'); return; }
if (!r.ok){
const t = await r.text();
console.error('Create event failed', r.status, t);
alert('Не удалось создать событие'); return;
}
document.querySelector('#eventCreateModal .btn-close')?.click();
form.reset();
syncByRecurrence(); syncByAllDay(); syncUntilCount();
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
updateWeekLists();
}catch(err){
console.error(err); alert('Ошибка сети');
console.error('Create event error', err);
const detail = err?.data?.detail || (typeof err?.data === 'string' ? err.data : '');
alert('Не удалось создать событие: ' + (detail || JSON.stringify(err?.data || {})));
}
});
});
</script>
</script>

View File

@ -63,7 +63,7 @@
<header class="feed-hd">
<div class="feed-ava" style="width:44px;height:44px;">
{% if req.employee.avatar %}
<img src="{{ req.employee.avatar_url }}" alt="">
<img src="{{ req.employee.avatar }}" alt="">
{% else %}
<i class="bi-person"></i>
{% endif %}

View File

@ -24,7 +24,7 @@
<header class="feed-hd">
<div class="feed-ava" style="width:48px;height:48px;">
{% if request_obj.employee.avatar %}
<img src="{{ request_obj.employee.avatar_url }}" alt="">
<img src="{{ request_obj.employee.avatar }}" alt="">
{% else %}<i class="bi-person"></i>{% endif %}
</div>

View File

@ -25,7 +25,7 @@
<header class="feed-hd">
<div class="feed-ava" style="width:48px;height:48px;">
{% if request_obj.employee.avatar %}
<img src="{{ request_obj.employee.avatar_url }}" alt="Аватар сотрудника"
<img src="{{ request_obj.employee.avatar }}" alt="Аватар сотрудника"
loading="lazy">
{% else %}
<i class="bi-person"></i>

View File

@ -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

View File

@ -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
# допускаем отсутствие такого события

View File

@ -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()