fix(notifications): восстановлены типы уведомлений, тестовый скрипт и все рабочие изменения
This commit is contained in:
parent
3ec09a0fa7
commit
3422f18f4f
273
PERSONAL_CALENDAR_FEATURE.md
Normal file
273
PERSONAL_CALENDAR_FEATURE.md
Normal file
@ -0,0 +1,273 @@
|
||||
# Личный календарь сотрудников
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
Добавлен третий тип календаря — **Личный календарь сотрудника**. Теперь система поддерживает:
|
||||
|
||||
1. **Календарь компании** (company) — глобальные события, доступ только у администраторов
|
||||
2. **Календарь отдела** (dept) — события отдела, требуется право `MANAGE_CALENDAR`
|
||||
3. **Личный календарь** (personal) — **НОВОЕ**: индивидуальный календарь каждого сотрудника
|
||||
|
||||
## Функциональность
|
||||
|
||||
### Личный календарь
|
||||
- ✅ Каждый сотрудник имеет собственный личный календарь
|
||||
- ✅ Полный контроль над своими событиями (создание, редактирование, удаление)
|
||||
- ✅ События личного календаря видны только владельцу
|
||||
- ✅ Другие пользователи не могут изменять чужие личные события
|
||||
- ✅ Администраторы также не могут редактировать личные календари (только просмотр)
|
||||
|
||||
## Изменения в Backend
|
||||
|
||||
### 1. Модель `CalendarEvent`
|
||||
|
||||
Добавлено поле `employee`:
|
||||
|
||||
```python
|
||||
employee = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="personal_calendar_events",
|
||||
verbose_name=_("Сотрудник"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Если задано — личное событие сотрудника."),
|
||||
)
|
||||
```
|
||||
|
||||
**Логика:**
|
||||
- `department=None, employee=None` → календарь компании
|
||||
- `department=set, employee=None` → календарь отдела
|
||||
- `department=None, employee=set` → личный календарь сотрудника
|
||||
|
||||
**Валидация:**
|
||||
- Событие не может одновременно принадлежать отделу и сотруднику
|
||||
- Добавлены свойства `is_personal` и обновлён `is_company`
|
||||
|
||||
### 2. Сериализаторы
|
||||
|
||||
**CalendarEventWriteSerializer:**
|
||||
- Добавлено поле `employee_id` в `fields`
|
||||
- Поддержка создания/обновления личных событий
|
||||
|
||||
### 3. Views (`CalendarEventsViewSet`)
|
||||
|
||||
**Новые методы:**
|
||||
```python
|
||||
def _employee_id(self, *, required: bool = False) -> Optional[int]:
|
||||
"""Извлекает employee_id из kwargs/query/body"""
|
||||
```
|
||||
|
||||
**Обновлённые методы:**
|
||||
|
||||
**`get_queryset()`:**
|
||||
```python
|
||||
# Приоритет: employee_id > department_id > company
|
||||
if emp is not None:
|
||||
return qs.filter(employee_id=emp, department__isnull=True)
|
||||
elif dep is not None:
|
||||
return qs.filter(department_id=dep, employee__isnull=True)
|
||||
else:
|
||||
return qs.filter(department__isnull=True, employee__isnull=True)
|
||||
```
|
||||
|
||||
**`get_permissions()`:**
|
||||
```python
|
||||
# Личный календарь — полный контроль только у владельца
|
||||
if emp is not None:
|
||||
if self.request.user.id == emp:
|
||||
return [IsAuthenticated()]
|
||||
else:
|
||||
return [DenyAll()] # Другие пользователи не могут изменять
|
||||
```
|
||||
|
||||
**`perform_create()`:**
|
||||
```python
|
||||
if emp is not None:
|
||||
serializer.save(
|
||||
employee_id=emp,
|
||||
department_id=None,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
```
|
||||
|
||||
**`permissions` endpoint:**
|
||||
```python
|
||||
elif event.employee_id is not None:
|
||||
# Личное событие — редактировать может только владелец
|
||||
if event.employee_id == user.id:
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
```
|
||||
|
||||
## Изменения в Frontend
|
||||
|
||||
### 1. Dropdown выбора календаря
|
||||
|
||||
**Desktop (`calendar_desktop.html`):**
|
||||
```html
|
||||
<li><button class="dropdown-item" type="button" data-cal="personal">Личный</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
|
||||
```
|
||||
|
||||
**Mobile (`calendar_mobile.html`):** — аналогично
|
||||
|
||||
### 2. JavaScript (`calendarWidget.js`)
|
||||
|
||||
**State:**
|
||||
```javascript
|
||||
const state = {
|
||||
type: 'company', // 'company' | 'dept' | 'personal'
|
||||
deptId: null,
|
||||
employeeId: null
|
||||
};
|
||||
```
|
||||
|
||||
**eventsUrl():**
|
||||
```javascript
|
||||
const eventsUrl = (deptId = null, employeeId = null) => {
|
||||
const u = new URL(API_EVENTS, location.origin);
|
||||
if (employeeId != null) {
|
||||
u.searchParams.set('employee_id', String(employeeId));
|
||||
} else if (deptId != null) {
|
||||
u.searchParams.set('department_id', String(deptId));
|
||||
}
|
||||
return u.pathname + u.search;
|
||||
};
|
||||
```
|
||||
|
||||
**handleChooserClick():**
|
||||
```javascript
|
||||
else if (type === 'personal') {
|
||||
state.type = 'personal';
|
||||
state.deptId = null;
|
||||
const userMeta = document.querySelector('meta[name="user-id"]');
|
||||
state.employeeId = userMeta ? userMeta.content : null;
|
||||
}
|
||||
```
|
||||
|
||||
**fetchEventsCombined():**
|
||||
```javascript
|
||||
if (state.type === 'personal' && state.employeeId) {
|
||||
params.employee_id = state.employeeId;
|
||||
}
|
||||
```
|
||||
|
||||
**Form submit:**
|
||||
```javascript
|
||||
if (state.type === 'personal') {
|
||||
payload.employee_id = Number(state.employeeId);
|
||||
} else if (state.type === 'dept') {
|
||||
payload.department_id = Number(state.deptId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Base Template
|
||||
|
||||
Добавлен meta-тег с ID пользователя:
|
||||
```html
|
||||
{% if user.is_authenticated %}
|
||||
<meta name="user-id" content="{{ user.id }}">
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Миграции
|
||||
|
||||
Создана миграция `0010_calendarevent_employee_and_more.py`:
|
||||
- Добавляет поле `employee` в модель `CalendarEvent`
|
||||
- Изменяет `help_text` поля `department`
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Сценарии для проверки:
|
||||
|
||||
1. **Переключение на личный календарь:**
|
||||
- Открыть dropdown → выбрать "Личный"
|
||||
- Должны отобразиться только личные события текущего пользователя
|
||||
|
||||
2. **Создание личного события:**
|
||||
- В режиме "Личный" создать событие
|
||||
- Событие должно сохраниться с `employee_id=текущий_пользователь`
|
||||
|
||||
3. **Редактирование личного события:**
|
||||
- Владелец может редактировать/удалять свои события
|
||||
- ПКМ на событии → контекстное меню должно показывать кнопки
|
||||
|
||||
4. **Изоляция календарей:**
|
||||
- События личного календаря не должны видеть другие пользователи
|
||||
- Администраторы могут видеть, но не редактировать чужие личные события
|
||||
|
||||
5. **Проверка прав:**
|
||||
- Попытка редактировать чужое личное событие должна вернуть 403
|
||||
- API endpoint `/permissions/` должен возвращать `can_edit=false` для чужих событий
|
||||
|
||||
## API Примеры
|
||||
|
||||
### Получить личные события
|
||||
```http
|
||||
GET /api/v1/calendar/events/?employee_id=123&start=2025-11-01&end=2025-11-30
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### Создать личное событие
|
||||
```http
|
||||
POST /api/v1/calendar/events/?employee_id=123
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"employee_id": 123,
|
||||
"title": "Личная встреча",
|
||||
"start_date": "2025-11-29",
|
||||
"all_day": true,
|
||||
"recurrence": "one_time"
|
||||
}
|
||||
```
|
||||
|
||||
### Проверить права на событие
|
||||
```http
|
||||
GET /api/v1/calendar/events/456/permissions/
|
||||
Authorization: Bearer <token>
|
||||
|
||||
Response:
|
||||
{
|
||||
"can_view": true,
|
||||
"can_edit": true, // true только для владельца
|
||||
"can_delete": true
|
||||
}
|
||||
```
|
||||
|
||||
## Безопасность
|
||||
|
||||
✅ **Изоляция данных:** события фильтруются по `employee_id` в QuerySet
|
||||
✅ **Проверка прав:** `get_permissions()` проверяет владельца перед изменениями
|
||||
✅ **Валидация:** модель запрещает одновременную привязку к отделу и сотруднику
|
||||
✅ **API защита:** все endpoints требуют авторизации
|
||||
|
||||
## Совместимость
|
||||
|
||||
✅ Обратная совместимость сохранена
|
||||
✅ Существующие события компании/отделов работают без изменений
|
||||
✅ Миграция безопасна (добавляет nullable поле)
|
||||
|
||||
## Файлы изменённые
|
||||
|
||||
### Backend:
|
||||
- `backend/calendar_app/models.py` — добавлено поле `employee`, валидация, свойства
|
||||
- `backend/api/v1/calendar/serializers.py` — добавлено поле `employee_id`
|
||||
- `backend/api/v1/calendar/views.py` — логика личных календарей
|
||||
- `backend/calendar_app/migrations/0010_calendarevent_employee_and_more.py` — миграция
|
||||
|
||||
### Frontend:
|
||||
- `backend/templates/base.html` — meta-тег `user-id`
|
||||
- `backend/templates/includes/components/calendar_desktop.html` — пункт "Личный"
|
||||
- `backend/templates/includes/components/calendar_mobile.html` — пункт "Личный"
|
||||
- `backend/static/js/components/calendarWidget.js` — полная поддержка личных календарей
|
||||
|
||||
## Дальнейшие улучшения
|
||||
|
||||
- [ ] Возможность "поделиться" личным событием с коллегами
|
||||
- [ ] Синхронизация с внешними календарями (Google, Outlook)
|
||||
- [ ] Уведомления о личных событиях
|
||||
- [ ] Экспорт личного календаря в iCal формат
|
||||
@ -22,12 +22,14 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
|
||||
help_text="Список дней недели для WEEKLY (0=Mon..6=Sun).",
|
||||
)
|
||||
department_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
employee_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CalendarEvent
|
||||
fields = (
|
||||
"id",
|
||||
"department_id",
|
||||
"employee_id",
|
||||
"title",
|
||||
"description",
|
||||
"start_date",
|
||||
|
||||
@ -11,6 +11,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.decorators import action as rest_action
|
||||
|
||||
from calendar_app.models import CalendarEvent
|
||||
from .serializers import (
|
||||
@ -19,8 +20,8 @@ from .serializers import (
|
||||
)
|
||||
from calendar_app.services.recurrence import expand_event_occurrences
|
||||
|
||||
from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed # пермишены для "компании"
|
||||
from employees.constants import DeptPerm # коды прав отделов
|
||||
from ..permissions import AdminOrDeptAllowed
|
||||
from employees.constants import DeptPerm
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -40,6 +41,9 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
"start_date", "start_time"
|
||||
)
|
||||
renderer_classes = [JSONRenderer]
|
||||
# ВАЖНО: Переопределяем глобальные DEFAULT_PERMISSION_CLASSES
|
||||
# Используем только get_permissions() для динамического определения прав
|
||||
permission_classes = []
|
||||
|
||||
# ---------- Внутренний пермишен для отделов ----------
|
||||
|
||||
@ -51,14 +55,54 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
# ---------- Подключение пермишенов ----------
|
||||
|
||||
def get_permissions(self):
|
||||
dep = self._dept_id(required=False)
|
||||
if dep is None:
|
||||
# компания
|
||||
if getattr(self, "action", None) == "list":
|
||||
# список событий компании — достаточно быть залогиненным
|
||||
"""Настройка прав доступа в зависимости от действия.
|
||||
|
||||
- list, retrieve: доступно всем авторизованным пользователям
|
||||
- create, update, partial_update, destroy для компании: требуется staff/superuser
|
||||
- create, update, partial_update, destroy для отдела: требуется MANAGE_CALENDAR
|
||||
- create, update, partial_update, destroy для личного календаря: только владелец
|
||||
"""
|
||||
action = getattr(self, "action", None)
|
||||
method = self.request.method if hasattr(self, 'request') else None
|
||||
|
||||
# Если action ещё не установлен, определяем по методу
|
||||
if action is None:
|
||||
if method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return [IsAuthenticated()]
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
# отдел
|
||||
elif method == 'POST':
|
||||
action = 'create'
|
||||
elif method in ['PUT', 'PATCH']:
|
||||
action = 'update'
|
||||
elif method == 'DELETE':
|
||||
action = 'destroy'
|
||||
|
||||
# Просмотр событий доступен всем авторизованным пользователям
|
||||
if action in ["list", "retrieve"]:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Для изменений требуются права управления календарём
|
||||
emp = self._employee_id(required=False)
|
||||
dep = self._dept_id(required=False)
|
||||
|
||||
# Личный календарь — полный контроль только у владельца
|
||||
if emp is not None:
|
||||
# Проверка, что текущий пользователь = владелец календаря
|
||||
if self.request.user.id == emp:
|
||||
return [IsAuthenticated()]
|
||||
else:
|
||||
# Другой пользователь не может изменять чужой личный календарь
|
||||
from rest_framework.permissions import BasePermission
|
||||
class DenyAll(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return False
|
||||
return [DenyAll()]
|
||||
|
||||
# Календарь компании — только администраторы
|
||||
if dep is None:
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
return [IsAdminUser()]
|
||||
|
||||
# Календарь отдела — требуется право MANAGE_CALENDAR
|
||||
self.kwargs = dict(self.kwargs)
|
||||
self.kwargs.setdefault("department_id", dep)
|
||||
return [self.ManageCalendarPerm()]
|
||||
@ -89,7 +133,7 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
Http404: При некорректном или отсутствующем department_id (когда required=True).
|
||||
|
||||
Returns:
|
||||
Optional[int]: PK отдела или None (компания).
|
||||
Optional[int]: PK отдела или None (компания или личный календарь).
|
||||
"""
|
||||
# nested-url вида: /departments/{department_id}/events/
|
||||
for key in ("department_pk", "department_id"):
|
||||
@ -121,11 +165,54 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
if required:
|
||||
raise Http404("Не указан department_id.")
|
||||
return None
|
||||
|
||||
def _employee_id(self, *, required: bool = False) -> Optional[int]:
|
||||
"""Читает employee_id из kwargs/query/body для личного календаря.
|
||||
|
||||
Args:
|
||||
required (bool): Если True — бросить 404 при отсутствии/ошибке.
|
||||
|
||||
Raises:
|
||||
Http404: При некорректном или отсутствующем employee_id (когда required=True).
|
||||
|
||||
Returns:
|
||||
Optional[int]: PK сотрудника или None (компания/отдел).
|
||||
"""
|
||||
# nested-url вида: /employees/{employee_id}/events/
|
||||
for key in ("employee_pk", "employee_id"):
|
||||
if key in self.kwargs:
|
||||
try:
|
||||
return int(self.kwargs[key])
|
||||
except Exception:
|
||||
raise Http404("Некорректный employee_id в URL.")
|
||||
|
||||
req = getattr(self, "request", None)
|
||||
if req is not None:
|
||||
for key in ("employee_id", "employee"):
|
||||
val = req.query_params.get(key)
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except Exception:
|
||||
raise Http404("Некорректный employee_id в query.")
|
||||
|
||||
data = getattr(req, "data", {}) if req is not None else {}
|
||||
if isinstance(data, dict):
|
||||
for key in ("employee_id", "employee"):
|
||||
if key in data and data[key] is not None:
|
||||
try:
|
||||
return int(data[key])
|
||||
except Exception:
|
||||
raise Http404("Некорректный employee_id в теле запроса.")
|
||||
|
||||
if required:
|
||||
raise Http404("Не указан employee_id.")
|
||||
return None
|
||||
|
||||
# ---------- QuerySet ----------
|
||||
|
||||
def get_queryset(self):
|
||||
"""QS для list фильтруем по компании/отделу, для detail — не фильтруем.
|
||||
"""QS для list фильтруем по компании/отделу/сотруднику, для detail — не фильтруем.
|
||||
|
||||
Returns:
|
||||
QuerySet: Набор для текущего действия.
|
||||
@ -136,7 +223,18 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
return qs
|
||||
|
||||
dep = self._dept_id(required=False)
|
||||
return qs.filter(department__isnull=True) if dep is None else qs.filter(department_id=dep)
|
||||
emp = self._employee_id(required=False)
|
||||
|
||||
# Приоритет: employee_id > department_id > company
|
||||
if emp is not None:
|
||||
# Личный календарь сотрудника
|
||||
return qs.filter(employee_id=emp, department__isnull=True)
|
||||
elif dep is not None:
|
||||
# Календарь отдела
|
||||
return qs.filter(department_id=dep, employee__isnull=True)
|
||||
else:
|
||||
# Календарь компании
|
||||
return qs.filter(department__isnull=True, employee__isnull=True)
|
||||
|
||||
# ---------- LIST: материализация повторов ----------
|
||||
|
||||
@ -187,16 +285,27 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
# ---------- Создание/изменение/удаление ----------
|
||||
|
||||
def perform_create(self, serializer: CalendarEventWriteSerializer) -> None:
|
||||
"""Сохраняет объект, подставляя department_id из контекста (если есть) и автора.
|
||||
"""Сохраняет объект, подставляя department_id/employee_id из контекста и автора.
|
||||
|
||||
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,
|
||||
)
|
||||
emp = self._employee_id(required=False)
|
||||
|
||||
# Приоритет: employee_id > department_id
|
||||
if emp is not None:
|
||||
serializer.save(
|
||||
employee_id=emp,
|
||||
department_id=None,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
else:
|
||||
serializer.save(
|
||||
department_id=dep if dep is not None else serializer.validated_data.get("department_id"),
|
||||
employee_id=None,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
cache.clear()
|
||||
|
||||
def perform_update(self, serializer: CalendarEventWriteSerializer) -> None:
|
||||
@ -207,4 +316,75 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
def perform_destroy(self, instance: CalendarEvent) -> None:
|
||||
"""Удаляет событие и чистит кеш."""
|
||||
instance.delete()
|
||||
cache.clear()
|
||||
cache.clear()
|
||||
|
||||
# ---------- Проверка прав ----------
|
||||
|
||||
@rest_action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
permission_classes=[IsAuthenticated],
|
||||
url_path="permissions",
|
||||
)
|
||||
def permissions(self, request, pk=None):
|
||||
"""Проверяет права пользователя на редактирование/удаление события.
|
||||
|
||||
Returns:
|
||||
Response: {
|
||||
"can_edit": bool,
|
||||
"can_delete": bool,
|
||||
"can_view": bool (всегда True для авторизованных)
|
||||
}
|
||||
"""
|
||||
event = self.get_object()
|
||||
user = request.user
|
||||
|
||||
# Просмотр доступен всем авторизованным
|
||||
can_view = True
|
||||
|
||||
# Права на редактирование и удаление
|
||||
can_edit = False
|
||||
can_delete = False
|
||||
|
||||
# Проверка прав
|
||||
if user.is_superuser or user.is_staff:
|
||||
# Админы могут всё
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
elif event.employee_id is not None:
|
||||
# Личное событие — редактировать может только владелец
|
||||
if event.employee_id == user.id:
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
elif event.department_id is None:
|
||||
# Событие компании — только админы (уже проверено выше)
|
||||
pass
|
||||
else:
|
||||
# Событие отдела — проверяем право MANAGE_CALENDAR
|
||||
try:
|
||||
from employees.models import EmployeeDepartment
|
||||
|
||||
# Проверяем, является ли пользователь руководителем отдела
|
||||
if hasattr(event.department, "head_id"):
|
||||
if event.department.head_id == user.id:
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
|
||||
# Проверяем наличие права MANAGE_CALENDAR
|
||||
if not can_edit:
|
||||
has_perm = EmployeeDepartment.objects.filter(
|
||||
employee_id=user.id,
|
||||
department_id=event.department_id,
|
||||
is_active=True,
|
||||
role__scoped_permissions__code=DeptPerm.MANAGE_CALENDAR,
|
||||
).exists()
|
||||
|
||||
if has_perm:
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking permissions: {e}")
|
||||
|
||||
return Response(
|
||||
{"can_view": can_view, "can_edit": can_edit, "can_delete": can_delete}
|
||||
)
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.4 on 2025-11-29 11:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('calendar_app', '0009_alter_calendarevent_options_and_more'),
|
||||
('employees', '0034_alter_employee_telegram'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='calendarevent',
|
||||
name='employee',
|
||||
field=models.ForeignKey(blank=True, help_text='Если задано — личное событие сотрудника.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='personal_calendar_events', 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='Отдел'),
|
||||
),
|
||||
]
|
||||
@ -24,10 +24,11 @@ class Recurrence(models.TextChoices):
|
||||
|
||||
|
||||
class CalendarEvent(models.Model):
|
||||
"""Событие календаря (компании или отдела) с поддержкой повторяемости.
|
||||
"""Событие календаря (компании, отдела или сотрудника) с поддержкой повторяемости.
|
||||
|
||||
Если `department` = NULL → событие компании (глобальное).
|
||||
Если `department` задан → событие конкретного отдела.
|
||||
Если `department` = NULL и `employee` = NULL → событие компании (глобальное).
|
||||
Если `department` задан и `employee` = NULL → событие конкретного отдела.
|
||||
Если `employee` задан и `department` = NULL → личное событие сотрудника.
|
||||
"""
|
||||
|
||||
# Область
|
||||
@ -38,7 +39,17 @@ class CalendarEvent(models.Model):
|
||||
verbose_name=_("Отдел"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Пусто — событие компании; задано — событие отдела."),
|
||||
help_text=_("Пусто — событие компании или сотрудника; задано — событие отдела."),
|
||||
)
|
||||
|
||||
employee = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="personal_calendar_events",
|
||||
verbose_name=_("Сотрудник"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Если задано — личное событие сотрудника."),
|
||||
)
|
||||
|
||||
# Основное
|
||||
@ -133,15 +144,26 @@ class CalendarEvent(models.Model):
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
scope = self.department or _("Компания")
|
||||
if self.employee_id:
|
||||
scope = f"Личный ({self.employee})"
|
||||
elif self.department_id:
|
||||
scope = str(self.department)
|
||||
else:
|
||||
scope = _("Компания")
|
||||
|
||||
if self.end_date:
|
||||
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})"
|
||||
|
||||
@property
|
||||
def is_company(self) -> bool:
|
||||
"""True если событие глобальное (без отдела)."""
|
||||
return self.department_id is None
|
||||
"""True если событие глобальное (без отдела и сотрудника)."""
|
||||
return self.department_id is None and self.employee_id is None
|
||||
|
||||
@property
|
||||
def is_personal(self) -> bool:
|
||||
"""True если событие личное (принадлежит конкретному сотруднику)."""
|
||||
return self.employee_id is not None
|
||||
|
||||
@property
|
||||
def has_time(self) -> bool:
|
||||
@ -155,6 +177,12 @@ class CalendarEvent(models.Model):
|
||||
Raises:
|
||||
ValidationError: При некорректных датах/времени или настройках повторения.
|
||||
"""
|
||||
# Проверка взаимоисключающих полей: department и employee
|
||||
if self.department_id and self.employee_id:
|
||||
raise ValidationError(
|
||||
_("Событие не может одновременно принадлежать отделу и сотруднику.")
|
||||
)
|
||||
|
||||
# База дат
|
||||
if self.end_date and self.start_date and self.end_date < self.start_date:
|
||||
raise ValidationError(
|
||||
|
||||
@ -194,13 +194,22 @@ def get_event_recipients(event):
|
||||
|
||||
- Для события компании - все активные сотрудники
|
||||
- Для события отдела - сотрудники этого отдела
|
||||
- Для личного события - только владелец
|
||||
"""
|
||||
if event.is_company:
|
||||
if event.is_personal:
|
||||
# Личное событие - только владелец
|
||||
return [event.employee] if event.employee else []
|
||||
elif event.is_company:
|
||||
# Событие компании - все активные сотрудники
|
||||
return list(Employee.objects.filter(is_active=True))
|
||||
elif event.department:
|
||||
# Событие отдела - сотрудники отдела
|
||||
return list(event.department.employees.filter(is_active=True))
|
||||
# Событие отдела - сотрудники через EmployeeDepartment
|
||||
from employees.models import EmployeeDepartment
|
||||
employee_ids = EmployeeDepartment.objects.filter(
|
||||
department=event.department,
|
||||
is_active=True
|
||||
).values_list('employee_id', flat=True)
|
||||
return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
|
||||
|
||||
return []
|
||||
|
||||
@ -208,7 +217,8 @@ def get_event_recipients(event):
|
||||
def get_calendar_url(event):
|
||||
"""
|
||||
Возвращает URL календаря в зависимости от типа события.
|
||||
Для всех типов событий используем параметр event_id для прямого открытия.
|
||||
"""
|
||||
if event.department:
|
||||
return f'/calendar/department/{event.department.id}/'
|
||||
return '/calendar/company/'
|
||||
# Возвращаем главную страницу календаря с параметром event_id
|
||||
# Это позволит открыть модал события автоматически
|
||||
return f'/?event_id={event.id}'
|
||||
|
||||
93
backend/check_chat_access.py
Normal file
93
backend/check_chat_access.py
Normal file
@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для проверки доступа к чатам
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Настройка Django
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from communications.models import Chat, ChatMembership
|
||||
from django.db.models import Q
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def check_chat_access():
|
||||
print("=" * 60)
|
||||
print("ПРОВЕРКА ДОСТУПА К ЧАТАМ")
|
||||
print("=" * 60)
|
||||
|
||||
# Получаем текущего пользователя (первого активного)
|
||||
users = User.objects.filter(is_active=True)
|
||||
if not users.exists():
|
||||
print("❌ Нет активных пользователей!")
|
||||
return
|
||||
|
||||
print(f"\n📋 Всего активных пользователей: {users.count()}")
|
||||
for user in users[:5]:
|
||||
print(f" - {user.username} (ID: {user.id})")
|
||||
|
||||
# Проверяем чаты
|
||||
print(f"\n📨 Всего чатов в системе: {Chat.objects.count()}")
|
||||
|
||||
for chat in Chat.objects.all()[:10]:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"📌 Чат ID: {chat.id}")
|
||||
print(f" Тип: {chat.type}")
|
||||
print(f" Название: {chat.name or '(без названия)'}")
|
||||
print(f" Создан: {chat.created_at}")
|
||||
|
||||
if chat.type == "private":
|
||||
participants = chat.participants.all()
|
||||
print(f" Участники (через participants): {[u.username for u in participants]}")
|
||||
|
||||
if chat.type == "department":
|
||||
print(f" Отдел: {chat.department}")
|
||||
if chat.department:
|
||||
# Используем get_participants для department чатов
|
||||
dept_users = chat.get_participants
|
||||
print(f" Сотрудники отдела: {[u.username or u.id for u in dept_users[:5]]}")
|
||||
|
||||
# Проверяем membership
|
||||
memberships = ChatMembership.objects.filter(chat=chat)
|
||||
if memberships.exists():
|
||||
print(f" Участники (через ChatMembership): {[m.user.username for m in memberships]}")
|
||||
|
||||
# Проверяем доступ для каждого пользователя
|
||||
print(f"\n Доступ пользователей к этому чату:")
|
||||
for user in users[:3]:
|
||||
departments = user.departments
|
||||
membership_chat_ids = ChatMembership.objects.filter(
|
||||
user=user
|
||||
).values_list('chat_id', flat=True)
|
||||
|
||||
has_access = Chat.objects.filter(
|
||||
Q(type="global")
|
||||
| Q(type="department", department__in=departments)
|
||||
| Q(type="private", participants=user)
|
||||
| Q(id__in=membership_chat_ids)
|
||||
).filter(id=chat.id).exists()
|
||||
|
||||
status = "✅" if has_access else "❌"
|
||||
print(f" {status} {user.username}: {'Доступ есть' if has_access else 'Доступа нет'}")
|
||||
|
||||
if not has_access:
|
||||
# Детальная проверка
|
||||
is_global = chat.type == "global"
|
||||
is_dept = chat.type == "department" and departments.filter(id=chat.department_id).exists()
|
||||
is_private = chat.type == "private" and chat.participants.filter(id=user.id).exists()
|
||||
is_member = chat.id in membership_chat_ids
|
||||
|
||||
print(f" - Глобальный: {is_global}")
|
||||
print(f" - Отдел: {is_dept}")
|
||||
print(f" - Личный участник: {is_private}")
|
||||
print(f" - Через membership: {is_member}")
|
||||
print(f" - Отделы пользователя: {[d.name for d in departments]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_chat_access()
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from datetime import datetime as dt
|
||||
from datetime import timezone as dt_tz
|
||||
|
||||
@ -8,6 +9,8 @@ from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import IntegrityError, models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from django.db.models import Count, F, Prefetch, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import JsonResponse
|
||||
@ -53,21 +56,43 @@ def _coerce_ts(val: str | None) -> datetime.datetime:
|
||||
def user_can_access_chat(chat: Chat, user) -> bool:
|
||||
if not chat or not user:
|
||||
return False
|
||||
|
||||
print(f"[CHAT DEBUG] user_can_access_chat: chat={chat.id}, type={chat.type}, user={user.id}")
|
||||
|
||||
if chat.type == "global":
|
||||
return True
|
||||
if chat.type in ("private", "group"): # Личные и групповые чаты через participants
|
||||
return chat.participants.filter(pk=user.pk).exists()
|
||||
|
||||
if chat.type == "private":
|
||||
# Личные чаты только через participants
|
||||
result = chat.participants.filter(pk=user.pk).exists()
|
||||
print(f"[CHAT DEBUG] Private chat, participants check: {result}")
|
||||
return result
|
||||
|
||||
if chat.type == "group":
|
||||
# Групповые чаты через participants ИЛИ ChatMembership
|
||||
in_participants = chat.participants.filter(pk=user.pk).exists()
|
||||
in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
result = in_participants or in_membership
|
||||
print(f"[CHAT DEBUG] Group chat, participants={in_participants}, membership={in_membership}, result={result}")
|
||||
return result
|
||||
|
||||
if chat.type == "department" and chat.department_id:
|
||||
return chat.get_participants.filter(pk=user.pk).exists()
|
||||
result = chat.get_participants.filter(pk=user.pk).exists()
|
||||
print(f"[CHAT DEBUG] Department chat, result={result}")
|
||||
return result
|
||||
|
||||
if chat.type in ("channel", "announcement"):
|
||||
# Для каналов и объявлений может быть include_all или membership
|
||||
if chat.include_all_employees:
|
||||
return user.is_active
|
||||
# Проверяем и participants и membership для гибкости
|
||||
return (
|
||||
result = (
|
||||
chat.participants.filter(pk=user.pk).exists()
|
||||
or ChatMembership.objects.filter(chat=chat, user=user).exists()
|
||||
)
|
||||
print(f"[CHAT DEBUG] Channel/announcement chat, result={result}")
|
||||
return result
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@ -151,6 +176,63 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
|
||||
context_object_name = "chat"
|
||||
form_class = MessageForm
|
||||
|
||||
def get_queryset(self):
|
||||
"""Возвращает только чаты, к которым пользователь имеет доступ"""
|
||||
user = self.request.user
|
||||
departments = user.departments
|
||||
|
||||
print(f"=" * 80)
|
||||
print(f"[CHAT DEBUG] ChatDetailView.get_queryset called")
|
||||
print(f"[CHAT DEBUG] User: {user.id} ({user.username or 'NO USERNAME'})")
|
||||
print(f"[CHAT DEBUG] Requested chat PK: {self.kwargs.get('pk')}")
|
||||
|
||||
logger.info(
|
||||
f"ChatDetailView.get_queryset: user={user.id} ({user.username}), "
|
||||
f"pk={self.kwargs.get('pk')}"
|
||||
)
|
||||
|
||||
# Получаем ID чатов через membership
|
||||
from communications.models import ChatMembership
|
||||
membership_chat_ids = list(
|
||||
ChatMembership.objects.filter(user=user).values_list('chat_id', flat=True)
|
||||
)
|
||||
|
||||
dept_ids = list(departments.values_list('id', flat=True))
|
||||
print(f"[CHAT DEBUG] User departments IDs: {dept_ids}")
|
||||
print(f"[CHAT DEBUG] User membership_chat_ids: {membership_chat_ids}")
|
||||
|
||||
logger.info(
|
||||
f"User departments: {list(departments.values_list('id', flat=True))}, "
|
||||
f"membership_chat_ids: {membership_chat_ids}"
|
||||
)
|
||||
|
||||
qs = Chat.objects.filter(
|
||||
Q(type="global")
|
||||
| Q(type="department", department__in=departments)
|
||||
| Q(type="private", participants=user)
|
||||
| Q(id__in=membership_chat_ids)
|
||||
).distinct()
|
||||
|
||||
available_ids = list(qs.values_list('id', flat=True))
|
||||
print(f"[CHAT DEBUG] Available chats for user: {available_ids}")
|
||||
print(f"[CHAT DEBUG] Access to requested chat: {self.kwargs.get('pk') in available_ids}")
|
||||
print(f"=" * 80)
|
||||
|
||||
logger.info(f"Available chats for user: {list(qs.values_list('id', flat=True))}")
|
||||
|
||||
return qs
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""Получаем объект чата с дополнительным логированием"""
|
||||
print(f"[CHAT DEBUG] get_object called")
|
||||
try:
|
||||
obj = super().get_object(queryset)
|
||||
print(f"[CHAT DEBUG] Object found: Chat {obj.id}, type={obj.type}")
|
||||
return obj
|
||||
except Exception as e:
|
||||
print(f"[CHAT DEBUG] Exception in get_object: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"communications:chat_detail", kwargs={"pk": self.object.pk}
|
||||
@ -164,10 +246,15 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
|
||||
chat: Chat = self.object
|
||||
user = self.request.user
|
||||
|
||||
print(f"[CHAT DEBUG] get_context_data called for chat {chat.id}")
|
||||
|
||||
# защита доступа
|
||||
if not self._user_has_access(chat, user):
|
||||
has_access = self._user_has_access(chat, user)
|
||||
print(f"[CHAT DEBUG] _user_has_access returned: {has_access}")
|
||||
|
||||
if not has_access:
|
||||
from django.http import Http404
|
||||
|
||||
print(f"[CHAT DEBUG] Raising Http404 because user has no access")
|
||||
raise Http404("Chat not found")
|
||||
|
||||
# сообщения + участники
|
||||
|
||||
@ -178,6 +178,7 @@ LOGGING = {
|
||||
"loggers": {
|
||||
"documents": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"bots": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"communications": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
|
||||
"django": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
"aiogram": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
# полезно видеть ошибки и отладку ldap3
|
||||
|
||||
File diff suppressed because one or more lines are too long
401
backend/static/js/components/postDetailModal.old.js
Normal file
401
backend/static/js/components/postDetailModal.old.js
Normal file
@ -0,0 +1,401 @@
|
||||
/**
|
||||
* postDetailModal.js
|
||||
* Модуль для открытия деталей поста в модальном окне
|
||||
* Полностью переработанная версия с правильной логикой определения автора
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Константы
|
||||
const INITIAL_COMMENTS_COUNT = 10;
|
||||
const DEBUG = true; // Включаем отладку для диагностики
|
||||
|
||||
// Состояние модуля
|
||||
let modal = null;
|
||||
let modalElement = null;
|
||||
let currentPost = null; // Сохраняем текущий пост
|
||||
|
||||
/**
|
||||
* Инициализация модального окна
|
||||
*/
|
||||
function initPostDetailModal() {
|
||||
modalElement = document.getElementById('postDetailModal');
|
||||
if (!modalElement) {
|
||||
console.warn('postDetailModal: элемент #postDetailModal не найден');
|
||||
return;
|
||||
}
|
||||
|
||||
modal = new bootstrap.Modal(modalElement);
|
||||
|
||||
// Очистка при закрытии
|
||||
modalElement.addEventListener('hidden.bs.modal', function() {
|
||||
const contentDiv = document.getElementById('postDetailContent');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
currentPost = null;
|
||||
});
|
||||
|
||||
// Добавляем обработчики на ссылки постов
|
||||
attachPostLinkHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение CSRF токена из cookie
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'csrftoken') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение JWT токена из cookie (для загрузки данных)
|
||||
*/
|
||||
function getAccessToken() {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=');
|
||||
if (name === 'access_token') {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка деталей поста через API
|
||||
*/
|
||||
async function loadPostDetail(postId) {
|
||||
const accessToken = getAccessToken();
|
||||
const headers = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/posts/${postId}/`, {
|
||||
method: 'GET',
|
||||
headers: headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = await response.json();
|
||||
|
||||
// Отладочная информация
|
||||
if (DEBUG) {
|
||||
console.log('=== POST DETAIL DEBUG ===');
|
||||
console.log('Post author:', post.author);
|
||||
console.log('Post author.id:', post.author?.id, 'type:', typeof post.author?.id);
|
||||
console.log('Post author_id:', post.author_id, 'type:', typeof post.author_id);
|
||||
if (post.comments && post.comments.length > 0) {
|
||||
console.log('First comment:', post.comments[0]);
|
||||
console.log('First comment author:', post.comments[0].author);
|
||||
console.log('First comment author.id:', post.comments[0].author?.id, 'type:', typeof post.comments[0].author?.id);
|
||||
console.log('First comment author_id:', post.comments[0].author_id, 'type:', typeof post.comments[0].author_id);
|
||||
}
|
||||
console.log('========================');
|
||||
}
|
||||
|
||||
currentPost = post;
|
||||
renderPostDetail(post);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки поста:', error);
|
||||
showError('Не удалось загрузить пост');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображение деталей поста в модальном окне
|
||||
*/
|
||||
function renderPostDetail(post) {
|
||||
const contentDiv = document.getElementById('postDetailContent');
|
||||
|
||||
// Определяем ID автора поста (пробуем разные поля)
|
||||
const postAuthorId = getAuthorId(post.author) || post.author_id;
|
||||
|
||||
if (DEBUG) {
|
||||
console.log('Rendering post with author ID:', postAuthorId);
|
||||
}
|
||||
|
||||
// Формируем HTML с кнопкой закрытия
|
||||
let html = '<button type="button" class="btn-close position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Закрыть" style="z-index: 1050;"></button>';
|
||||
html += '<article class="card">';
|
||||
|
||||
// Заголовок с автором
|
||||
html += renderPostHeader(post);
|
||||
|
||||
// Изображение
|
||||
if (post.image) {
|
||||
html += `<div class="feed-img"><img src="${post.image}" alt="${escapeHtml(post.title || 'Публикация')}"></div>`;
|
||||
}
|
||||
|
||||
// Тело
|
||||
html += '<div class="card-body">';
|
||||
html += `<h3 class="feed-title mb-3">${escapeHtml(post.title || 'Без названия')}</h3>`;
|
||||
if (post.body) {
|
||||
html += `<div class="feed-text mb-3">${formatBody(post.body)}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Футер с лайками
|
||||
html += renderPostFooter(post);
|
||||
|
||||
// Секция комментариев
|
||||
html += renderCommentsSection(post, postAuthorId);
|
||||
|
||||
html += '</article>';
|
||||
|
||||
contentDiv.innerHTML = html;
|
||||
|
||||
// Сохраняем данные для динамической подгрузки
|
||||
if (post.comments && post.comments.length > 0) {
|
||||
contentDiv.dataset.allComments = JSON.stringify(post.comments);
|
||||
contentDiv.dataset.postAuthorId = postAuthorId;
|
||||
}
|
||||
|
||||
// Прокручиваем к последнему комментарию
|
||||
scrollToBottom();
|
||||
|
||||
// Добавляем обработчики
|
||||
attachLikeHandlers();
|
||||
attachLoadMoreCommentsHandler();
|
||||
attachCommentFormHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображение комментария
|
||||
* @param {Object} comment - Объект комментария
|
||||
* @param {number} postAuthorId - ID автора поста
|
||||
*/
|
||||
function renderComment(comment, postAuthorId) {
|
||||
// Приводим оба ID к числам для корректного сравнения
|
||||
const commentAuthorId = comment.author?.id ? parseInt(comment.author.id) : null;
|
||||
const postAuthorIdNum = postAuthorId ? parseInt(postAuthorId) : null;
|
||||
const isAuthor = commentAuthorId !== null && postAuthorIdNum !== null && commentAuthorId === postAuthorIdNum;
|
||||
|
||||
const commentClass = isAuthor ? 'comment-item comment-item--author' : 'comment-item';
|
||||
|
||||
let html = `<div class="${commentClass}">`;
|
||||
html += '<div class="comment-header">';
|
||||
|
||||
// Автор и дата
|
||||
const authorName = comment.author?.full_name || 'Аноним';
|
||||
const authorId = comment.author?.id;
|
||||
|
||||
if (authorId) {
|
||||
html += `<a href="/employees/employee/${authorId}/" class="comment-author">${authorName}</a>`;
|
||||
} else {
|
||||
html += `<span class="comment-author">${authorName}</span>`;
|
||||
}
|
||||
|
||||
html += '<span class="comment-separator">·</span>';
|
||||
html += `<time class="comment-date">${formatDate(comment.created_at)}</time>`;
|
||||
html += '</div>';
|
||||
|
||||
// Текст комментария
|
||||
html += `<div class="comment-body">${formatBody(comment.text)}</div>`;
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование даты
|
||||
*/
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return 'Сегодня, ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days === 1) {
|
||||
return 'Вчера, ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days < 7) {
|
||||
return days + ' дн. назад';
|
||||
} else {
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование текста (простая обработка переносов строк)
|
||||
*/
|
||||
function formatBody(text) {
|
||||
return text.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать ошибку
|
||||
*/
|
||||
function showError(message) {
|
||||
const contentDiv = document.getElementById('postDetailContent');
|
||||
contentDiv.innerHTML = `
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi-exclamation-triangle"></i> ${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть модальное окно с постом
|
||||
*/
|
||||
function openPostModal(postId) {
|
||||
if (!modal) {
|
||||
console.error('Modal не инициализирован');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
loadPostDetail(postId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавление обработчиков на ссылки постов
|
||||
*/
|
||||
function attachPostLinkHandlers() {
|
||||
// Обработка кликов по ссылкам на посты
|
||||
document.addEventListener('click', function(e) {
|
||||
const link = e.target.closest('a[data-post-link]');
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
const postId = link.dataset.postId;
|
||||
if (postId) {
|
||||
openPostModal(postId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик для кнопки "Показать ещё комментарии"
|
||||
*/
|
||||
function attachLoadMoreCommentsHandler() {
|
||||
const loadMoreBtn = document.querySelector('.load-more-comments');
|
||||
if (!loadMoreBtn) return;
|
||||
|
||||
loadMoreBtn.addEventListener('click', function() {
|
||||
const contentDiv = document.getElementById('postDetailContent');
|
||||
const allComments = JSON.parse(contentDiv.dataset.allComments || '[]');
|
||||
const postAuthorId = parseInt(contentDiv.dataset.postAuthorId);
|
||||
const commentsContainer = document.getElementById('commentsListContainer');
|
||||
|
||||
if (!commentsContainer || allComments.length === 0) return;
|
||||
|
||||
// Сохраняем текущую позицию скролла и высоту
|
||||
const scrollHeight = commentsContainer.scrollHeight;
|
||||
const scrollTop = commentsContainer.scrollTop;
|
||||
|
||||
// Показываем все комментарии
|
||||
let html = '';
|
||||
allComments.forEach(comment => {
|
||||
html += renderComment(comment, postAuthorId);
|
||||
});
|
||||
|
||||
commentsContainer.innerHTML = html;
|
||||
|
||||
// Восстанавливаем позицию скролла (с учётом добавленного контента)
|
||||
const newScrollHeight = commentsContainer.scrollHeight;
|
||||
const heightDiff = newScrollHeight - scrollHeight;
|
||||
commentsContainer.scrollTop = scrollTop + heightDiff;
|
||||
|
||||
// Удаляем кнопку после загрузки
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчики лайков в модальном окне
|
||||
*/
|
||||
function attachLikeHandlers() {
|
||||
const likeButtons = document.querySelectorAll('#postDetailContent .like-btn');
|
||||
likeButtons.forEach(btn => {
|
||||
btn.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
const postId = this.dataset.postId;
|
||||
await toggleLike(postId, this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключение лайка
|
||||
*/
|
||||
async function toggleLike(postId, button) {
|
||||
const csrfToken = getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
alert('Необходимо авторизоваться');
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = button.querySelector('i');
|
||||
const isLiked = icon.classList.contains('bi-heart-fill');
|
||||
const endpoint = isLiked ? 'unlike' : 'like';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/posts/${postId}/${endpoint}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Обновляем иконку и счетчик
|
||||
if (isLiked) {
|
||||
icon.classList.remove('bi-heart-fill', 'text-danger');
|
||||
icon.classList.add('bi-heart');
|
||||
} else {
|
||||
icon.classList.remove('bi-heart');
|
||||
icon.classList.add('bi-heart-fill', 'text-danger');
|
||||
}
|
||||
|
||||
// Обновляем счетчик
|
||||
const countText = button.childNodes[1];
|
||||
if (countText) {
|
||||
countText.textContent = ' ' + (data.likes_count || 0);
|
||||
}
|
||||
} else {
|
||||
alert('Ошибка при обработке лайка');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка:', error);
|
||||
alert('Ошибка сети');
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPostDetailModal);
|
||||
} else {
|
||||
initPostDetailModal();
|
||||
}
|
||||
|
||||
// Экспорт для использования извне
|
||||
window.openPostModal = openPostModal;
|
||||
|
||||
})();
|
||||
@ -118,6 +118,113 @@ export function initThemeToggle(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет тему для всех emoji picker на странице.
|
||||
* @param {string} theme - 'light' или 'dark'
|
||||
*/
|
||||
function updateEmojiPickerTheme(theme) {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Получаем все emoji picker элементы
|
||||
const pickers = document.querySelectorAll('emoji-picker');
|
||||
|
||||
pickers.forEach(picker => {
|
||||
// Устанавливаем CSS-переменные для emoji-picker-element
|
||||
if (isDark) {
|
||||
// Темная тема
|
||||
picker.style.setProperty('--background', '#212529');
|
||||
picker.style.setProperty('--rgb-background', '33, 37, 41');
|
||||
picker.style.setProperty('--color', '#dee2e6');
|
||||
picker.style.setProperty('--rgb-color', '222, 226, 230');
|
||||
picker.style.setProperty('--secondary-color', '#adb5bd');
|
||||
picker.style.setProperty('--rgb-accent', '13, 110, 253');
|
||||
picker.style.setProperty('--border-color', 'rgba(255, 255, 255, 0.1)');
|
||||
picker.style.setProperty('--input-border-color', 'rgba(255, 255, 255, 0.15)');
|
||||
picker.style.setProperty('--input-background', '#343a40');
|
||||
picker.style.setProperty('--hover-background', '#343a40');
|
||||
} else {
|
||||
// Светлая тема
|
||||
picker.style.setProperty('--background', '#ffffff');
|
||||
picker.style.setProperty('--rgb-background', '255, 255, 255');
|
||||
picker.style.setProperty('--color', '#212529');
|
||||
picker.style.setProperty('--rgb-color', '33, 37, 41');
|
||||
picker.style.setProperty('--secondary-color', '#6c757d');
|
||||
picker.style.setProperty('--rgb-accent', '13, 110, 253');
|
||||
picker.style.setProperty('--border-color', 'rgba(0, 0, 0, 0.08)');
|
||||
picker.style.setProperty('--input-border-color', 'rgba(0, 0, 0, 0.1)');
|
||||
picker.style.setProperty('--input-background', '#f8f9fa');
|
||||
picker.style.setProperty('--hover-background', '#f8f9fa');
|
||||
}
|
||||
|
||||
// Инжектим стили скроллбара в Shadow DOM
|
||||
if (picker.shadowRoot) {
|
||||
// Проверяем, не добавлены ли уже стили
|
||||
let styleEl = picker.shadowRoot.querySelector('#custom-scrollbar-styles');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'custom-scrollbar-styles';
|
||||
picker.shadowRoot.appendChild(styleEl);
|
||||
}
|
||||
|
||||
// Устанавливаем стили скроллбара в зависимости от темы
|
||||
const scrollbarColor = isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
|
||||
const scrollbarBg = isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)';
|
||||
|
||||
styleEl.textContent = `
|
||||
.tabpanel {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${scrollbarColor} ${scrollbarBg};
|
||||
}
|
||||
.tabpanel::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.tabpanel::-webkit-scrollbar-track {
|
||||
background: ${scrollbarBg};
|
||||
}
|
||||
.tabpanel::-webkit-scrollbar-thumb {
|
||||
background: ${scrollbarColor};
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tabpanel::-webkit-scrollbar-thumb:hover {
|
||||
background: ${isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.4)'};
|
||||
}
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт наблюдатель за добавлением новых emoji picker на страницу.
|
||||
*/
|
||||
function setupEmojiPickerObserver() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === 1) { // Element node
|
||||
// Проверяем сам элемент
|
||||
if (node.tagName === 'EMOJI-PICKER') {
|
||||
const currentTheme = getEffectiveTheme(getSavedMode(), mediaQuery);
|
||||
updateEmojiPickerTheme(currentTheme);
|
||||
}
|
||||
// Проверяем дочерние элементы
|
||||
const emojiPickers = node.querySelectorAll?.('emoji-picker');
|
||||
if (emojiPickers?.length > 0) {
|
||||
const currentTheme = getEffectiveTheme(getSavedMode(), mediaQuery);
|
||||
updateEmojiPickerTheme(currentTheme);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
return observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет режим темы к документу.
|
||||
* @param {string} mode - Режим темы
|
||||
@ -132,6 +239,9 @@ export function initThemeToggle(options = {}) {
|
||||
}
|
||||
|
||||
updateButtons(mode);
|
||||
|
||||
// Обновляем тему для emoji picker
|
||||
updateEmojiPickerTheme(effectiveTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -165,6 +275,9 @@ export function initThemeToggle(options = {}) {
|
||||
function handleDOMContentLoaded() {
|
||||
const savedMode = getSavedMode();
|
||||
applyMode(savedMode, false);
|
||||
|
||||
// Инициализируем наблюдатель за новыми emoji picker
|
||||
setupEmojiPickerObserver();
|
||||
}
|
||||
|
||||
// Установка обработчиков
|
||||
@ -177,6 +290,9 @@ export function initThemeToggle(options = {}) {
|
||||
handleDOMContentLoaded();
|
||||
}
|
||||
|
||||
// Создаём переменную для хранения observer
|
||||
let emojiObserver = null;
|
||||
|
||||
/**
|
||||
* Функция для удаления всех обработчиков.
|
||||
*/
|
||||
@ -184,6 +300,11 @@ export function initThemeToggle(options = {}) {
|
||||
document.removeEventListener('DOMContentLoaded', handleDOMContentLoaded);
|
||||
document.body.removeEventListener('click', handleButtonClick);
|
||||
mediaQuery.removeEventListener('change', handleMediaQueryChange);
|
||||
|
||||
// Отключаем наблюдатель
|
||||
if (emojiObserver) {
|
||||
emojiObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -109,20 +109,14 @@
|
||||
// Обёртка для комментариев с sticky формой
|
||||
.comments-section-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 300px;
|
||||
max-height: 65vh; // Максимальная высота всей секции
|
||||
}
|
||||
|
||||
// Скроллируемая область с комментариями
|
||||
.comments-scrollable {
|
||||
flex: 1 1 auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; // Важно для правильной работы flex
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
// Firefox скроллбар
|
||||
scrollbar-width: thin;
|
||||
@ -149,10 +143,10 @@
|
||||
|
||||
// Sticky форма для комментариев
|
||||
.comment-form-sticky {
|
||||
flex: 0 0 auto;
|
||||
position: relative; // Изменено с sticky на relative
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bs-body-bg);
|
||||
z-index: 10;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
@ -166,22 +166,28 @@ $card-cap-color: null !default;
|
||||
|
||||
// Базовые стили карточки - применяем эффект жидкого стекла
|
||||
.card {
|
||||
// Размытие фона (glassmorphism)
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
// Прозрачный фон с лёгким оттенком для светлой темы (как в тёмной)
|
||||
--bs-card-bg: transparent;
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
|
||||
// Рамка для светлой темы
|
||||
// Размытие фона (glassmorphism) - основа эффекта жидкого стекла
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
|
||||
// Тонкая рамка для светлой темы
|
||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
|
||||
// Плавные переходы
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
|
||||
// Hover эффект
|
||||
// Hover эффект - усиливаем эффект
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border-color: rgba(0, 0, 0, 0.12) !important;
|
||||
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(200%);
|
||||
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
{% if user.is_authenticated %}
|
||||
<meta name="api-access" content="{% api_access_token request %}">
|
||||
<meta name="user-id" content="{{ user.id }}">
|
||||
{% endif %}
|
||||
|
||||
{# КРИТИЧНО: Применяем тему ДО загрузки CSS для предотвращения FOUC #}
|
||||
|
||||
@ -170,7 +170,7 @@
|
||||
{% 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 %}
|
||||
<div id="c{{ cid }}" class="comment-item{% if c_author_id == user.id %} comment-item--own{% endif %}">
|
||||
<div id="c{{ cid }}" class="comment-item">
|
||||
<div class="comment-header">
|
||||
{% if c_author_id %}
|
||||
<a href="{% url 'employees:employee_detail' c_author_id %}" class="comment-author">
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
{# Контекстное меню для действий с событиями календаря #}
|
||||
<div id="calendarContextMenu" class="calendar-context-menu" style="display: none; position: absolute; z-index: 9999;">
|
||||
<div class="dropdown-menu show">
|
||||
<button class="dropdown-item" id="contextMenuView" type="button">
|
||||
<i class="bi-eye me-2"></i>Просмотреть
|
||||
</button>
|
||||
<button class="dropdown-item" id="contextMenuEdit" type="button" data-requires-permission="edit">
|
||||
<i class="bi-pencil me-2"></i>Редактировать
|
||||
</button>
|
||||
<div class="dropdown-divider" data-requires-permission="delete"></div>
|
||||
<button class="dropdown-item text-danger" id="contextMenuDelete" type="button" data-requires-permission="delete">
|
||||
<i class="bi-trash me-2"></i>Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendar-context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.calendar-context-menu .dropdown-menu {
|
||||
position: static;
|
||||
float: none;
|
||||
border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);
|
||||
box-shadow: var(--bs-dropdown-box-shadow);
|
||||
}
|
||||
|
||||
.calendar-context-menu .dropdown-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-context-menu .dropdown-item:active {
|
||||
background-color: var(--bs-dropdown-link-active-bg);
|
||||
color: var(--bs-dropdown-link-active-color);
|
||||
}
|
||||
|
||||
.calendar-context-menu .dropdown-item[data-requires-permission].d-none {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@ -6,7 +6,7 @@
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-1 min-w-0">
|
||||
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
|
||||
|
||||
{# Выпадающий выбор: Компания / Отделы #}
|
||||
{# Выпадающий выбор: Компания / Отделы / Личный #}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle text-truncate" type="button"
|
||||
id="calendarChooserBtn" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
@ -14,6 +14,8 @@
|
||||
Компания
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="calendarChooserBtn" id="calendarChooserMenu">
|
||||
<li><button class="dropdown-item" type="button" data-cal="personal">Личный</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{# пункты отделов добавим из JS по data-dept-id #}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-1 min-w-0">
|
||||
<h5 class="offcanvas-title mb-0" id="rightbarOffcanvasLabel">Календарь</h5>
|
||||
|
||||
{# Выпадающий выбор: Компания / Отделы #}
|
||||
{# Выпадающий выбор: Личный / Компания / Отделы #}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle text-truncate" type="button"
|
||||
id="calendarChooserBtnMobile" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
@ -20,6 +20,8 @@
|
||||
Компания
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="calendarChooserBtnMobile" id="calendarChooserMenuMobile">
|
||||
<li><button class="dropdown-item" type="button" data-cal="personal">Личный</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{# пункты отделов добавим из JS по data-dept-id #}
|
||||
|
||||
@ -24,8 +24,6 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-danger me-auto" id="btnDeleteEvent">Удалить</button>
|
||||
<button type="button" class="btn btn-primary" id="btnEditEvent">Редактировать</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
{# Модальные окна #}
|
||||
{% include "includes/components/calendar_modal_create.html" %}
|
||||
{% include "includes/components/calendar_modal_details.html" %}
|
||||
{% include "includes/components/calendar_context_menu.html" %}
|
||||
|
||||
{# JavaScript инициализация #}
|
||||
{% include "includes/components/calendar_scripts.html" %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user