Corp/backend/templates/feed/post_detail.html
Игорь Надеин 0772e4549a **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.
2025-09-09 17:55:15 +03:00

280 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}{% firstof post.title "Публикация" %}{% endblock %}
{% block content %}
{% firstof post.id post.pk as post_id %}
{% firstof post.author.id post.author.pk post.author_id as author_id %}
{% firstof post.department.id post.department.pk post.department_id as dept_id %}
<div class="container">
<div class="row gx-0">
<div class="col-12">
<!-- Навигация и действия -->
<div class="d-flex align-items-center gap-2 mb-3">
{% if post.type == 'company' %}
<a href="{% url 'feed:feed_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К ленте
</a>
{% elif post.type == 'department' and dept_id %}
<a href="{% url 'employees:department_detail' dept_id %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К отделу
</a>
{% else %}
{% if author_id %}
<a href="{% url 'feed:employee_feed' author_id %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К публикациям автора
</a>
{% else %}
<a href="{% url 'feed:feed_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К ленте
</a>
{% endif %}
{% endif %}
<span class="small text-secondary ms-auto">
</span>
{# Пин — без скобок в условии #}
{% if post_id %}
{% if post.type == 'company' and user.is_staff %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% elif post.type == 'department' and dept_id %}
{% if user.is_staff %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% elif post.department and post.department.head_id == user.id %}
<a href="{% url 'feed:pin_post' post_id %}" class="btn btn-ghost">
<i class="bi-pin-angle"></i> {% if post.pinned %}Открепить{% else %}Закрепить{% endif %}
</a>
{% endif %}
{% endif %}
{% endif %}
{# Редактирование #}
{% if post_id %}
{% if user.is_staff or user.id == post.author_id %}
<a href="{% url 'feed:post_update' post_id %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% elif post.type == 'department' and post.department and post.department.head_id == user.id %}
<a href="{% url 'feed:post_update' post_id %}" class="btn btn-ghost">
<i class="bi-pencil"></i> Редактировать
</a>
{% endif %}
{% endif %}
</div>
<!-- Карточка поста -->
<article class="feed-card" data-post-card>
<header class="feed-hd">
<div class="feed-ava">
{% with a=post.author %}
{% if a %}
{% 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>
{% endif %}
{% endwith %}
</div>
<div class="feed-meta">
<div class="feed-author">
{% if author_id %}
<a href="{% url 'employees:employee_detail' author_id %}" class="text-decoration-none">
{% with a=post.author %}
{% if a %}
{% firstof a.full_name a.get_full_name as nm %}
{% if nm %}{{ nm }}{% else %}
{% if a.first_name or a.last_name %}
{{ a.first_name }}{% if a.last_name %} {{ a.last_name }}{% endif %}
{% else %}
{% firstof a.username a.email "Сотрудник" %}
{% endif %}
{% endif %}
{% else %}Сотрудник #{{ author_id }}{% endif %}
{% endwith %}
</a>
{% else %}Автор не указан{% endif %}
</div>
<div class="feed-sub">
{% if post.created_at %}
{{ post.created_at_display }}
{% endif %}
{% if post.type == 'department' %}
{% firstof post.department.name post.department_name as dept_name %}
{% if dept_name %} · {{ dept_name }}{% endif %}
{% endif %}
</div>
</div>
{% if post.pinned %}
<span class="feed-pin"><i class="bi-pin-angle"></i> закреплено</span>
{% endif %}
</header>
{% if post.image %}
{% firstof post.image.url post.image as img_url %}
{% if img_url %}
<div class="feed-img">
<img src="{{ img_url }}" alt="{% firstof post.title 'Публикация' %}">
</div>
{% endif %}
{% endif %}
<div class="feed-body">
<h1 class="feed-title fs-4 mb-2">{% firstof post.title "Без названия" %}</h1>
<div class="fs-6 lh-base">{{ post.body|linebreaks }}</div>
{% if post.attachment %}
{% firstof post.attachment.url post.attachment as att_url %}
{% if att_url %}
<div class="mt-3">
<a href="{{ att_url }}" target="_blank" class="btn btn-ghost">
<i class="bi-paperclip"></i> Вложение
</a>
</div>
{% endif %}
{% endif %}
</div>
<footer class="feed-actions">
<a href="#comments" class="btn btn-ghost">
<i class="bi-chat-dots"></i> Комментарии ({{ comments|length }})
</a>
<span class="ms-auto"></span>
{% if user.is_authenticated and post_id %}
<form method="post" action="{% url 'feed:toggle_like' post_id %}">
{% csrf_token %}
<button type="submit"
class="btn btn-ghost {% if post.is_liked or is_liked %}is-liked{% endif %}"
data-like-post="{{ post_id }}"
data-like-url="{% url 'feed:toggle_like' post_id %}"
aria-pressed="{% if post.is_liked or is_liked %}true{% else %}false{% endif %}">
<i class="bi {% if post.is_liked or is_liked %}bi-heart-fill{% else %}bi-heart{% endif %}"></i>
<span data-like-count>{{ post.likes_count|default:0 }}</span>
</button>
</form>
{% else %}
<span class="small text-secondary d-inline-flex align-items-center gap-1">
<i class="bi-heart{% if post.is_liked %}-fill{% endif %}"></i>
<span data-like-count>{{ post.likes_count|default:0 }}</span>
</span>
{% endif %}
</footer>
</article>
<!-- Комментарии -->
<section id="comments" class="mt-4">
<div class="feed-header mb-2">
<i class="bi-chat-dots fs-4 text-primary"></i>
<h3 class="title mb-0">Комментарии</h3>
<span class="small text-secondary ms-2">({{ comments|length }})</span>
</div>
{% if comments %}
<div class="feed-stream">
{% for c in comments %}
{% firstof c.id c.pk as cid %}
{% firstof c.author.id c.author.pk c.author_id as c_author_id %}
<article id="c{{ cid }}" class="feed-card">
<header class="feed-hd">
<div class="feed-ava" style="width:36px;height:36px;">
{% with a=c.author %}
{% if a %}
{% 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>
{% endif %}
{% endwith %}
</div>
<div class="feed-meta">
<div class="feed-author">
{% if c_author_id %}
<a href="{% url 'employees:employee_detail' c_author_id %}" class="text-decoration-none">
{% with a=c.author %}
{% if a %}
{% firstof a.full_name a.get_full_name as nm %}
{% if nm %}{{ nm }}{% else %}
{% if a.first_name or a.last_name %}
{{ a.first_name }}{% if a.last_name %} {{ a.last_name }}{% endif %}
{% else %}
{% firstof a.username a.email "Сотрудник" %}
{% endif %}
{% endif %}
{% else %}Сотрудник #{{ c_author_id }}{% endif %}
{% endwith %}
</a>
{% else %}Автор{% endif %}
</div>
<div class="feed-sub">
{% if c.created_at %}
<time datetime="{{ c.created_at }}">
{{ c.created_at_display }}
</time>
{% endif %}
</div>
</div>
{% if request.user.is_staff or request.user.id == c.author_id %}
<div class="ms-auto d-flex gap-2">
{% if cid %}
<a href="{% url 'feed:comment_update' cid %}" class="btn btn-ghost btn-sm">
<i class="bi-pencil"></i>
</a>
<a href="{% url 'feed:comment_delete' cid %}" class="btn btn-ghost btn-sm text-danger">
<i class="bi-trash"></i>
</a>
{% endif %}
</div>
{% endif %}
</header>
<div class="feed-body">
<div class="fs-6">{{ c.text|linebreaks }}</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info shadow-sm rounded-3">Пока нет комментариев.</div>
{% endif %}
</section>
<!-- Форма добавления комментария -->
{% if user.is_authenticated and post_id %}
<div class="feed-card mt-3">
<div class="feed-body">
<h6 class="mb-2">Оставить комментарий</h6>
<form method="post" action="">
{% csrf_token %}
<div class="input-group">
<textarea name="text" class="form-control" rows="2" placeholder="Напишите комментарий…"></textarea>
<button class="btn btn-primary" type="submit">
<i class="bi-send"></i>
</button>
</div>
</form>
</div>
</div>
{% else %}
<div class="text-center mt-3">
<a class="btn btn-primary" href="{% url 'auth_front:login' %}?next={{ request.path|urlencode }}">
Войдите, чтобы комментировать
</a>
</div>
{% endif %}
<div class="mb-5"></div>
</div>
</div>
</div>
{% endblock %}