fix(notifications): восстановлены типы уведомлений, тестовый скрипт и все рабочие изменения

This commit is contained in:
admin i 2025-11-29 16:41:03 +03:00
parent 3ec09a0fa7
commit 3422f18f4f
21 changed files with 1331 additions and 62 deletions

View 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 формат

View File

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

View File

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

View File

@ -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='Отдел'),
),
]

View File

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

View File

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

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

View File

@ -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")
# сообщения + участники

View File

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

View 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;
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" %}