feat(ui+calendar+auth+depts): большой апдейт календаря, форм авторизации и списка отделов
Календарь (rightbar) : • Цвет события по умолчанию (#0d6efd) + удобная палитра (input[type=color] + быстрые свотчи). • Детали события в модале (центрирование, перенос модала в <body> при необходимости), кнопки Редактировать/Удалить. • Редактирование: заполнение формы из деталки, PATCH по API, возврат к календарю, безопасные обработчики (навешиваются один раз). • Еженедельная лента теперь собирает события со всех доступных календарей (компания + отделы пользователя), дедупликация по id. • В ленте недели: учитывается цвет события, показ источника (Компания / Отдел: N), location и краткое описание (≤20 символов). • Устранены дубли отделов в селекторе календарей; нормализованы id; аккуратные ошибки/403; безопасная работа без токена. Список отделов: • Поиск по названию и руководителю (data-haystack); счётчик сотрудников обновляется только по видимым строкам. • Кнопка «Создать отдел» в стиле списка (вся строка — кликабельна), видна только при наличии прав/статуса. • Чёткое центрирование текста и иконки без кастомных стилей (Bootstrap-утилиты), стрелка фиксирована справа. • Прокрутка только у списка (#deptList): шапка, поиск и кнопка остаются на месте; высота как в employees_list.html. Auth / Регистрация / Логин / Верификация email / Сброс пароля: • Шаблоны приведены к единой теме: уважаем переключатель тем из base.html, устранён «мигание» темы. • RegistrationForm: валидация контактов (хотя бы один из Telegram/WhatsApp/WeChat), пароли совпадают. • Поля телефона (регистрация/логин): inputmode='numeric', pattern для E.164, подсказки; на бэке по-прежнему нормализуется. • Регистрация: превью аватара с заглушкой, radio для пола; убраны поля навыков/должности из формы (они не нужны здесь). • API регистрации: принимаем avatar через Base64ImageField и сохраняем user.avatar при создании; остальная логика без изменений. • Сообщение об ошибке «нужно хотя бы одно контактное поле» возвращается в ключе detail (совместимо с тестом). • Логин/верификация/сброс: единые заголовки, floating labels, иконки, соответствие теме. Техническое: • Аккуратные утилиты JS: извлечение токена, формирование URL с диапазонами дат, нормализация событий, один раз навешиваемые обработчики. • Безопасные fallbacks для отсутствующих узлов/данных; кросс-браузерные мелочи (overflow-scrolling, aria-атрибуты). Примечания: • Миграций БД нет. Изменены шаблоны, фронтовый JS и вью/сериализатор регистрации. • Если пути файлов в проекте отличаются, коммит всё равно включает все изменённые файлы (git add -A).
This commit is contained in:
parent
0772e4549a
commit
d488b748ad
@ -1,4 +1,5 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
@ -15,6 +16,7 @@ from employees.models import (
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..serializers import Base64ImageField
|
||||
from .utils import _normalize_phone
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
@ -29,9 +31,25 @@ class EmailVerifySerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
"""Сериализатор регистрации.
|
||||
|
||||
Проверяет контакты, нормализует телефон до E.164 и ограничивает допустимые
|
||||
значения некоторых полей.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: При нарушении правил валидации.
|
||||
"""
|
||||
|
||||
first_name = serializers.CharField(max_length=150)
|
||||
last_name = serializers.CharField(max_length=150)
|
||||
phone_number = serializers.CharField(max_length=100)
|
||||
phone_number = serializers.CharField(
|
||||
max_length=100, required=False, allow_blank=True
|
||||
)
|
||||
# alias, если прислали "phone": положим в phone_number
|
||||
phone = serializers.CharField(
|
||||
max_length=100, required=False, allow_blank=True, write_only=True
|
||||
)
|
||||
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(min_length=6)
|
||||
birth_date = serializers.DateField()
|
||||
@ -40,13 +58,67 @@ class RegisterSerializer(serializers.Serializer):
|
||||
whatsapp = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
wechat = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
# Доп. поля модели можно тоже принять, но они опциональны:
|
||||
avatar = Base64ImageField(required=False, allow_null=True)
|
||||
patronymic = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
gender = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
gender = serializers.ChoiceField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
choices=((0, "Не указан"), (1, "Мужской"), (2, "Женский")),
|
||||
)
|
||||
|
||||
position = serializers.IntegerField(required=False, allow_null=True)
|
||||
skills = serializers.ListField(child=serializers.IntegerField(), required=False)
|
||||
|
||||
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Глобальная валидация: контакты + телефон (alias) + нормализация E.164.
|
||||
|
||||
Args:
|
||||
attrs: Входные атрибуты.
|
||||
|
||||
Returns:
|
||||
Отвалидированные атрибуты.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: Если нет контактов или телефон невалиден.
|
||||
"""
|
||||
# контакты: нужен хотя бы один
|
||||
if not (
|
||||
(attrs.get("telegram") or "").strip()
|
||||
or (attrs.get("whatsapp") or "").strip()
|
||||
or (attrs.get("wechat") or "").strip()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"non_field_errors": [
|
||||
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# alias "phone" → phone_number (если не задан)
|
||||
if not attrs.get("phone_number") and attrs.get("phone"):
|
||||
attrs["phone_number"] = attrs["phone"]
|
||||
|
||||
# нормализация телефона
|
||||
norm = _normalize_phone(attrs.get("phone_number"))
|
||||
if not norm:
|
||||
raise serializers.ValidationError(
|
||||
{"phone_number": "Неверный номер телефона (требуется формат E.164)."}
|
||||
)
|
||||
attrs["phone_number"] = norm
|
||||
|
||||
# (опционально) проверим, что position существует — чтобы не молча проигнорировать
|
||||
pos_id = attrs.get("position")
|
||||
if pos_id is not None:
|
||||
exists = Position.objects.filter(pk=pos_id).exists()
|
||||
if not exists:
|
||||
raise serializers.ValidationError(
|
||||
{"position": "Указанная должность не найдена."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class SkillSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@ -627,4 +699,4 @@ class RemoveMemberInput(serializers.Serializer):
|
||||
class DepartmentBriefSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Department
|
||||
fields = ("id", "name")
|
||||
fields = ("id", "name")
|
||||
|
||||
202
backend/api/v1/employees/utils.py
Normal file
202
backend/api/v1/employees/utils.py
Normal file
@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import phonenumbers
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DeptPerm,
|
||||
EmployeeDepartment,
|
||||
)
|
||||
from phonenumbers import PhoneNumberFormat
|
||||
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _to_bool(val: str | None) -> bool | None:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip().lower()
|
||||
if s in {"1", "true", "yes", "да"}:
|
||||
return True
|
||||
if s in {"0", "false", "no", "нет"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_phone(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
if phonenumbers is None:
|
||||
return str(raw).strip()
|
||||
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
|
||||
try:
|
||||
pn = phonenumbers.parse(str(raw), region)
|
||||
if not phonenumbers.is_valid_number(pn):
|
||||
return None
|
||||
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_phone_field() -> str | None:
|
||||
for n in ("phone", "phone_number", "mobile", "msisdn", "tel"):
|
||||
if any(f.name == n for f in Employee._meta.fields):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _validate_head_active(
|
||||
dept: Department,
|
||||
employee_id: int,
|
||||
require_email_verified: bool = True,
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""
|
||||
Проверить, можно ли назначить сотрудника руководителем отдела.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dept : Department
|
||||
Отдел, в который назначается руководитель.
|
||||
employee_id : int
|
||||
Кандидат на роль руководителя.
|
||||
require_email_verified : bool, optional
|
||||
Требовать ли подтверждение email. Для действий, совершаемых не-руководителем,
|
||||
оставляем True; для действий текущего руководителя можно ослабить до False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(True, None) | (False, {"head_id": [..]})
|
||||
True — назначать можно; False — нельзя (причина в словаре).
|
||||
"""
|
||||
employee_model = Department._meta.get_field("head").remote_field.model
|
||||
emp = employee_model.objects.filter(id=employee_id).first()
|
||||
if not emp:
|
||||
return False, {"head_id": ["Employee not found."]}
|
||||
|
||||
# Должен быть включён аккаунт
|
||||
if getattr(emp, "is_active", True) is False:
|
||||
return False, {"head_id": ["Employee is inactive."]}
|
||||
|
||||
# Требование верификации email — опционально
|
||||
if require_email_verified and getattr(emp, "email_verified", True) is False:
|
||||
return False, {"head_id": ["Employee is inactive."]}
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _ensure_department_permissions() -> list[dict]:
|
||||
"""
|
||||
Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES.
|
||||
Возвращает список словарей {id, code, name} в порядке CHOICES.
|
||||
"""
|
||||
items: list[dict] = []
|
||||
# пробежимся по CHOICES, создадим/обновим имя, соберём выдачу
|
||||
for code, label in DeptPerm.CHOICES:
|
||||
obj, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code, defaults={"name": label}
|
||||
)
|
||||
# если имя в БД отстаёт от CHOICES — мягко синхронизируем
|
||||
if obj.name != label:
|
||||
obj.name = label
|
||||
obj.save(update_fields=["name"])
|
||||
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
|
||||
return items
|
||||
|
||||
|
||||
def _head_choices_for_dept(dept: Department, serializer) -> list[dict]:
|
||||
"""
|
||||
Вернёт список кандидатов для назначения руководителя отдела.
|
||||
|
||||
Формат элемента:
|
||||
{
|
||||
"id": int, # ID сотрудника
|
||||
"name": str, # display_name из EmployeeBriefSerializer
|
||||
"email": str # email сотрудника (может быть пустой строкой)
|
||||
}
|
||||
"""
|
||||
choices, seen = [], set()
|
||||
qs = (
|
||||
EmployeeDepartment.objects.filter(department_id=dept.id)
|
||||
.select_related("employee")
|
||||
.order_by(
|
||||
"employee__last_name",
|
||||
"employee__first_name",
|
||||
"employee__patronymic",
|
||||
"employee_id",
|
||||
)
|
||||
)
|
||||
for link in qs:
|
||||
data = serializer(link.employee).data
|
||||
if data["id"] not in seen:
|
||||
choices.append(
|
||||
{
|
||||
"id": data["id"],
|
||||
"name": data["display_name"],
|
||||
"email": data.get("email", "") or "",
|
||||
}
|
||||
)
|
||||
seen.add(data["id"])
|
||||
|
||||
if dept.head_id and dept.head_id not in seen:
|
||||
head_data = serializer(dept.head).data
|
||||
choices.insert(
|
||||
0,
|
||||
{
|
||||
"id": head_data["id"],
|
||||
"name": head_data["display_name"],
|
||||
"email": head_data.get("email", "") or "",
|
||||
},
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def _perm_choices_synced() -> list[dict]:
|
||||
"""
|
||||
Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES.
|
||||
"""
|
||||
items = []
|
||||
for code, label in DeptPerm.CHOICES:
|
||||
obj, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code, defaults={"name": label}
|
||||
)
|
||||
if obj.name != label:
|
||||
obj.name = label
|
||||
obj.save(update_fields=["name"])
|
||||
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
|
||||
return items
|
||||
|
||||
|
||||
def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
|
||||
"""
|
||||
Возвращает список линков отдела:
|
||||
[{ "employee": <EmployeeBriefSerializer.data>, "role": {"id","name"}|None, "is_active": bool }, ...]
|
||||
"""
|
||||
links: list[dict] = []
|
||||
qs = (
|
||||
EmployeeDepartment.objects.filter(department_id=dept.id)
|
||||
.select_related("employee", "role")
|
||||
.order_by(
|
||||
"employee__last_name",
|
||||
"employee__first_name",
|
||||
"employee__patronymic",
|
||||
"employee_id",
|
||||
)
|
||||
)
|
||||
for link in qs:
|
||||
emp_data = serializer(link.employee).data # содержит display_name
|
||||
role = {"id": link.role_id, "name": link.role.name} if link.role_id else None
|
||||
links.append(
|
||||
{"employee": emp_data, "role": role, "is_active": bool(link.is_active)}
|
||||
)
|
||||
|
||||
# гарантируем присутствие head
|
||||
if dept.head_id and all(item["employee"]["id"] != dept.head_id for item in links):
|
||||
head_data = serializer(dept.head).data
|
||||
links.insert(0, {"employee": head_data, "role": None, "is_active": True})
|
||||
|
||||
return links
|
||||
@ -3,267 +3,45 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import phonenumbers
|
||||
from common.emails import send_templated_mail
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef,
|
||||
Prefetch, Q, Subquery, Value, When)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from employees.constants import ACTION_DISMISSED
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
DeptPerm,
|
||||
EmployeeAction,
|
||||
EmployeeDepartment,
|
||||
Position,
|
||||
Skill,
|
||||
)
|
||||
from phonenumbers import PhoneNumberFormat
|
||||
from rest_framework import filters, generics, permissions, status, viewsets
|
||||
from employees.models import (Department, DepartmentPermission, DepartmentRole,
|
||||
DeptPerm, EmployeeAction, EmployeeDepartment,
|
||||
Position, Skill)
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import (
|
||||
AllowAny,
|
||||
IsAuthenticated,
|
||||
)
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
from ..permissions import (
|
||||
|
||||
AdminOrActionOrModelPerms,
|
||||
AdminOrDeptAllowed,
|
||||
IsSelfOrStaff,
|
||||
has_dept_perm,
|
||||
)
|
||||
from .serializers import (
|
||||
AddMemberInput,
|
||||
DepartmentBriefSerializer,
|
||||
DepartmentRoleSerializer,
|
||||
DepartmentSerializer,
|
||||
EmailSerializer,
|
||||
EmailVerifySerializer,
|
||||
EmployeeActionSerializer,
|
||||
EmployeeBriefSerializer,
|
||||
EmployeeListSerializer,
|
||||
EmployeeSerializer,
|
||||
GroupSerializer,
|
||||
PositionSerializer,
|
||||
RegisterSerializer,
|
||||
RemoveMemberInput,
|
||||
SetHeadInput,
|
||||
SetMemberRoleInput,
|
||||
SkillSerializer,
|
||||
)
|
||||
from ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed,
|
||||
IsSelfOrStaff, has_dept_perm)
|
||||
from .serializers import (AddMemberInput, DepartmentBriefSerializer,
|
||||
DepartmentRoleSerializer, DepartmentSerializer,
|
||||
EmailSerializer, EmailVerifySerializer,
|
||||
EmployeeActionSerializer, EmployeeBriefSerializer,
|
||||
EmployeeListSerializer, EmployeeSerializer,
|
||||
GroupSerializer, PositionSerializer,
|
||||
RegisterSerializer, RemoveMemberInput, SetHeadInput,
|
||||
SetMemberRoleInput, SkillSerializer)
|
||||
from .utils import (_build_links_for_dept, _detect_phone_field,
|
||||
_ensure_department_permissions, _head_choices_for_dept,
|
||||
_normalize_phone, _perm_choices_synced, _to_bool,
|
||||
_validate_head_active)
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _to_bool(val: str | None) -> bool | None:
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip().lower()
|
||||
if s in {"1", "true", "yes", "да"}:
|
||||
return True
|
||||
if s in {"0", "false", "no", "нет"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_phone(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
if phonenumbers is None:
|
||||
return str(raw).strip()
|
||||
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
|
||||
try:
|
||||
pn = phonenumbers.parse(str(raw), region)
|
||||
if not phonenumbers.is_valid_number(pn):
|
||||
return None
|
||||
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_phone_field() -> str | None:
|
||||
for n in ("phone", "phone_number", "mobile", "msisdn", "tel"):
|
||||
if any(f.name == n for f in Employee._meta.fields):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _validate_head_active(
|
||||
dept: Department,
|
||||
employee_id: int,
|
||||
require_email_verified: bool = True,
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""
|
||||
Проверить, можно ли назначить сотрудника руководителем отдела.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dept : Department
|
||||
Отдел, в который назначается руководитель.
|
||||
employee_id : int
|
||||
Кандидат на роль руководителя.
|
||||
require_email_verified : bool, optional
|
||||
Требовать ли подтверждение email. Для действий, совершаемых не-руководителем,
|
||||
оставляем True; для действий текущего руководителя можно ослабить до False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(True, None) | (False, {"head_id": [..]})
|
||||
True — назначать можно; False — нельзя (причина в словаре).
|
||||
"""
|
||||
employee_model = Department._meta.get_field("head").remote_field.model
|
||||
emp = employee_model.objects.filter(id=employee_id).first()
|
||||
if not emp:
|
||||
return False, {"head_id": ["Employee not found."]}
|
||||
|
||||
# Должен быть включён аккаунт
|
||||
if getattr(emp, "is_active", True) is False:
|
||||
return False, {"head_id": ["Employee is inactive."]}
|
||||
|
||||
# Требование верификации email — опционально
|
||||
if require_email_verified and getattr(emp, "email_verified", True) is False:
|
||||
return False, {"head_id": ["Employee is inactive."]}
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def _ensure_department_permissions() -> list[dict]:
|
||||
"""
|
||||
Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES.
|
||||
Возвращает список словарей {id, code, name} в порядке CHOICES.
|
||||
"""
|
||||
items: list[dict] = []
|
||||
# пробежимся по CHOICES, создадим/обновим имя, соберём выдачу
|
||||
for code, label in DeptPerm.CHOICES:
|
||||
obj, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code, defaults={"name": label}
|
||||
)
|
||||
# если имя в БД отстаёт от CHOICES — мягко синхронизируем
|
||||
if obj.name != label:
|
||||
obj.name = label
|
||||
obj.save(update_fields=["name"])
|
||||
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
|
||||
return items
|
||||
|
||||
|
||||
def _build_links_for_dept(dept: Department) -> list[dict]:
|
||||
"""
|
||||
Возвращает список линков отдела:
|
||||
[{ "employee": <EmployeeBriefSerializer.data>, "role": {"id","name"}|None, "is_active": bool }, ...]
|
||||
"""
|
||||
links: list[dict] = []
|
||||
qs = (
|
||||
EmployeeDepartment.objects.filter(department_id=dept.id)
|
||||
.select_related("employee", "role")
|
||||
.order_by(
|
||||
"employee__last_name",
|
||||
"employee__first_name",
|
||||
"employee__patronymic",
|
||||
"employee_id",
|
||||
)
|
||||
)
|
||||
for link in qs:
|
||||
emp_data = EmployeeBriefSerializer(link.employee).data # содержит display_name
|
||||
role = {"id": link.role_id, "name": link.role.name} if link.role_id else None
|
||||
links.append(
|
||||
{"employee": emp_data, "role": role, "is_active": bool(link.is_active)}
|
||||
)
|
||||
|
||||
# гарантируем присутствие head
|
||||
if dept.head_id and all(item["employee"]["id"] != dept.head_id for item in links):
|
||||
head_data = EmployeeBriefSerializer(dept.head).data
|
||||
links.insert(0, {"employee": head_data, "role": None, "is_active": True})
|
||||
|
||||
return links
|
||||
|
||||
|
||||
def _head_choices_for_dept(dept: Department) -> list[dict]:
|
||||
"""
|
||||
Вернёт список кандидатов для назначения руководителя отдела.
|
||||
|
||||
Формат элемента:
|
||||
{
|
||||
"id": int, # ID сотрудника
|
||||
"name": str, # display_name из EmployeeBriefSerializer
|
||||
"email": str # email сотрудника (может быть пустой строкой)
|
||||
}
|
||||
"""
|
||||
choices, seen = [], set()
|
||||
qs = (
|
||||
EmployeeDepartment.objects.filter(department_id=dept.id)
|
||||
.select_related("employee")
|
||||
.order_by(
|
||||
"employee__last_name",
|
||||
"employee__first_name",
|
||||
"employee__patronymic",
|
||||
"employee_id",
|
||||
)
|
||||
)
|
||||
for link in qs:
|
||||
data = EmployeeBriefSerializer(link.employee).data
|
||||
if data["id"] not in seen:
|
||||
choices.append(
|
||||
{
|
||||
"id": data["id"],
|
||||
"name": data["display_name"],
|
||||
"email": data.get("email", "") or "",
|
||||
}
|
||||
)
|
||||
seen.add(data["id"])
|
||||
|
||||
if dept.head_id and dept.head_id not in seen:
|
||||
head_data = EmployeeBriefSerializer(dept.head).data
|
||||
choices.insert(
|
||||
0,
|
||||
{
|
||||
"id": head_data["id"],
|
||||
"name": head_data["display_name"],
|
||||
"email": head_data.get("email", "") or "",
|
||||
},
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
def _perm_choices_synced() -> list[dict]:
|
||||
"""
|
||||
Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES.
|
||||
"""
|
||||
items = []
|
||||
for code, label in DeptPerm.CHOICES:
|
||||
obj, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code, defaults={"name": label}
|
||||
)
|
||||
if obj.name != label:
|
||||
obj.name = label
|
||||
obj.save(update_fields=["name"])
|
||||
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
|
||||
return items
|
||||
|
||||
|
||||
PHONE_FIELD = _detect_phone_field()
|
||||
|
||||
|
||||
@ -394,17 +172,22 @@ class VerifyEmailAPIView(APIView):
|
||||
|
||||
|
||||
class RegisterAPIView(APIView):
|
||||
"""
|
||||
POST /api/v1/auth/register/
|
||||
Требует: first_name, last_name, phone_number, email, birth_date, password,
|
||||
и хотя бы один из: telegram / whatsapp / wechat
|
||||
"""Регистрация пользователя.
|
||||
|
||||
Принимает JSON или multipart/form-data. Поле `avatar` можно передавать:
|
||||
- как файл (`multipart/form-data`);
|
||||
- как base64/data URI — если используете `Base64ImageField`.
|
||||
|
||||
Важно: хотя бы одно из полей `telegram/whatsapp/wechat` должно быть заполнено.
|
||||
"""
|
||||
|
||||
throttle_scope = "anon"
|
||||
permission_classes = [AllowAny]
|
||||
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
# 0) Хард-проверка контактов (как в тестах ожидается detail)
|
||||
if not (
|
||||
request.data.get("telegram")
|
||||
or request.data.get("whatsapp")
|
||||
@ -425,27 +208,21 @@ class RegisterAPIView(APIView):
|
||||
password = v["password"]
|
||||
phone_raw = v.get("phone_number") or request.data.get("phone")
|
||||
phone_norm = _normalize_phone(phone_raw)
|
||||
|
||||
# Телефон обязателен и должен быть валиден (E.164)
|
||||
if not phone_norm:
|
||||
return Response({"ok": False, "error": "invalid_phone"}, status=400)
|
||||
|
||||
# 1) Дубликат телефона — жёстко 400
|
||||
if Employee.objects.filter(phone_number=phone_norm).exists():
|
||||
return Response({"ok": False, "error": "phone_taken"}, status=400)
|
||||
|
||||
# 2) Логика по email
|
||||
user = Employee.objects.filter(email__iexact=email).first()
|
||||
if user and user.email_verified:
|
||||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||||
if user and not user.email_verified:
|
||||
# без регенерации кода/письма — просто мягкий ответ
|
||||
return Response(
|
||||
{"ok": True, "pending_verification": True, "user_id": user.id},
|
||||
status=200,
|
||||
)
|
||||
|
||||
# 3) Создание нового
|
||||
extra = {
|
||||
"first_name": v["first_name"],
|
||||
"last_name": v["last_name"],
|
||||
@ -453,33 +230,44 @@ class RegisterAPIView(APIView):
|
||||
"telegram": v.get("telegram", ""),
|
||||
"whatsapp": v.get("whatsapp", ""),
|
||||
"wechat": v.get("wechat", ""),
|
||||
# можно сразу пробросить, если ваш create_user это поддерживает:
|
||||
# "patronymic": v.get("patronymic") or "",
|
||||
# "gender": v.get("gender"),
|
||||
}
|
||||
phone_for_create = phone_norm
|
||||
|
||||
try:
|
||||
user = Employee.objects.create_user(
|
||||
email=email,
|
||||
password=password,
|
||||
phone_number=phone_for_create,
|
||||
phone_number=phone_norm,
|
||||
**extra,
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
msg = str(e).lower()
|
||||
if "phone" in msg or "phone_number" in msg:
|
||||
if "phone" in msg:
|
||||
return Response({"ok": False, "error": "phone_taken"}, status=400)
|
||||
if "email" in msg:
|
||||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||||
raise
|
||||
|
||||
# доп. поля
|
||||
# 🔧 ВАЖНО: присвоить аватар и опциональные атрибуты
|
||||
avatar = v.get("avatar")
|
||||
if avatar:
|
||||
user.avatar = (
|
||||
avatar # ImageField принимает InMemoryUploadedFile/ContentFile
|
||||
)
|
||||
if v.get("patronymic"):
|
||||
user.patronymic = v["patronymic"]
|
||||
if v.get("gender") is not None:
|
||||
user.gender = v["gender"]
|
||||
pos_id = v.get("position")
|
||||
if pos_id:
|
||||
Position.objects.filter(pk=pos_id).first() and setattr(
|
||||
user, "position_id", pos_id
|
||||
user.position_id = (
|
||||
pos_id if Position.objects.filter(pk=pos_id).exists() else None
|
||||
)
|
||||
skills_ids = v.get("skills") or []
|
||||
if skills_ids:
|
||||
user.save() # сохранить перед m2m
|
||||
user.skills.set(Skill.objects.filter(pk__in=skills_ids))
|
||||
user.save()
|
||||
|
||||
@ -489,6 +277,7 @@ class RegisterAPIView(APIView):
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"is_active": user.is_active,
|
||||
# при желании можно вернуть data URI (если хотите): "avatar": Base64ImageField().to_representation(user.avatar)
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
@ -760,7 +549,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
Список участников отдела в формате, удобном для фронта-шаблона.
|
||||
"""
|
||||
dept = self.get_object()
|
||||
links = _build_links_for_dept(dept)
|
||||
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
|
||||
return Response({"count": len(links), "results": links}, status=200)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="user-perms")
|
||||
@ -806,9 +595,9 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
roles_data = DepartmentRoleSerializer(roles_qs, many=True).data
|
||||
|
||||
links = _build_links_for_dept(dept)
|
||||
links = _build_links_for_dept(dept, EmployeeBriefSerializer)
|
||||
perm_choices = _perm_choices_synced()
|
||||
head_choices = _head_choices_for_dept(dept)
|
||||
head_choices = _head_choices_for_dept(dept, EmployeeBriefSerializer)
|
||||
user_perms = {
|
||||
"is_head": (
|
||||
request.user.id == dept.head_id
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.4 on 2025-09-10 05:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('calendar_app', '0008_calendarevent_source_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='calendarevent',
|
||||
options={'ordering': ['department', 'start_date', 'start_time'], 'verbose_name': 'Событие календаря', 'verbose_name_plural': 'События календаря'},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='calendarevent',
|
||||
name='cal_event_date_time_range_valid',
|
||||
),
|
||||
]
|
||||
@ -131,9 +131,6 @@ class CalendarEvent(models.Model):
|
||||
condition=~models.Q(source=""),
|
||||
),
|
||||
]
|
||||
permissions = [
|
||||
("manage_department_events", "Может управлять событиями отдела"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
scope = self.department or _("Компания")
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
# backend/employees/forms_auth.py
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.core.exceptions import ValidationError
|
||||
from employees.models import Employee
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class EmailOrPhoneAuthenticationForm(AuthenticationForm):
|
||||
@ -23,10 +28,34 @@ class EmailOrPhoneAuthenticationForm(AuthenticationForm):
|
||||
|
||||
|
||||
class RegistrationForm(forms.Form):
|
||||
"""Форма регистрации для POST /api/v1/auth/register/.
|
||||
|
||||
Валидирует обязательные поля, совпадение паролей и наличие хотя бы одного контактного способа.
|
||||
Метод :meth:`to_api_payload` готовит JSON-совместимый payload под ваш DRF-сериализатор,
|
||||
включая аватар, закодированный как data URI (base64).
|
||||
|
||||
Notes:
|
||||
Вызывайте :meth:`to_api_payload` только после успешной валидации (`form.is_valid()`).
|
||||
|
||||
Raises:
|
||||
ValidationError: Бросается не напрямую, а через `self.add_error(...)` — форма станет невалидной.
|
||||
"""
|
||||
|
||||
# Обязательные поля
|
||||
first_name = forms.CharField(max_length=150, label="Имя")
|
||||
last_name = forms.CharField(max_length=150, label="Фамилия")
|
||||
phone_number = forms.CharField(max_length=100, label="Номер телефона")
|
||||
phone_number = forms.CharField(
|
||||
max_length=17, # + и до 15 цифр (с запасом)
|
||||
label="Номер телефона",
|
||||
widget=forms.TextInput(attrs={
|
||||
"type": "tel",
|
||||
"inputmode": "numeric",
|
||||
"pattern": r"^\+?\d{10,15}$",
|
||||
"autocomplete": "tel",
|
||||
"data-phone-only": "1",
|
||||
"title": "Введите номер в формате +79991234567 (10–15 цифр)",
|
||||
})
|
||||
)
|
||||
email = forms.EmailField(label="Email")
|
||||
birth_date = forms.DateField(
|
||||
label="Дата рождения", widget=forms.DateInput(attrs={"type": "date"})
|
||||
@ -45,53 +74,158 @@ class RegistrationForm(forms.Form):
|
||||
patronymic = forms.CharField(required=False, label="Отчество")
|
||||
gender = forms.IntegerField(required=False, label="Пол (0/1/2)")
|
||||
position = forms.IntegerField(required=False, label="ID должности")
|
||||
# skills — список ID через запятую (для простоты)
|
||||
# skills — список ID через запятую
|
||||
skills = forms.CharField(required=False, label="Навыки (ID через запятую)")
|
||||
avatar = forms.ImageField(required=False, label="Аватар")
|
||||
|
||||
def clean(self):
|
||||
def clean(self) -> Dict[str, Any]:
|
||||
"""Глобальная валидация формы.
|
||||
|
||||
Проверяет:
|
||||
- наличие хотя бы одного контактного поля (Telegram/WhatsApp/WeChat);
|
||||
- корректность паролей (оба заполнены и совпадают).
|
||||
|
||||
Returns:
|
||||
dict: Очищенные данные формы.
|
||||
"""
|
||||
data = super().clean()
|
||||
|
||||
# хотя бы один контакт
|
||||
if not (data.get("telegram") or data.get("whatsapp") or data.get("wechat")):
|
||||
raise ValidationError(
|
||||
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram"
|
||||
if not (
|
||||
(data.get("telegram") or "").strip()
|
||||
or (data.get("whatsapp") or "").strip()
|
||||
or (data.get("wechat") or "").strip()
|
||||
):
|
||||
self.add_error(
|
||||
None,
|
||||
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
|
||||
)
|
||||
|
||||
# пароли совпадают
|
||||
p1 = data.get("password1")
|
||||
p2 = data.get("password2")
|
||||
if p1 or p2:
|
||||
if p1 != p2:
|
||||
raise ValidationError("Пароли не совпадают.")
|
||||
else:
|
||||
raise ValidationError("Введите пароль и его подтверждение.")
|
||||
# пароли
|
||||
p1 = (data.get("password1") or "").strip()
|
||||
p2 = (data.get("password2") or "").strip()
|
||||
if not p1:
|
||||
self.add_error("password1", "Введите пароль.")
|
||||
if not p2:
|
||||
self.add_error("password2", "Подтвердите пароль.")
|
||||
if p1 and p2 and p1 != p2:
|
||||
self.add_error("password2", "Пароли не совпадают.")
|
||||
|
||||
return data
|
||||
|
||||
def skills_ids(self) -> list[int]:
|
||||
def skills_ids(self) -> List[int]:
|
||||
"""Возвращает список ID навыков из текстового поля.
|
||||
|
||||
Returns:
|
||||
list[int]: Список целых ID. Пустой, если поле пустое или состоит из мусора.
|
||||
"""
|
||||
raw = (self.cleaned_data.get("skills") or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
out: list[int] = []
|
||||
out: List[int] = []
|
||||
for chunk in raw.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
s = chunk.strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
out.append(int(chunk))
|
||||
out.append(int(s))
|
||||
except ValueError:
|
||||
# игнорируем мусор
|
||||
pass
|
||||
continue
|
||||
return out
|
||||
|
||||
def to_api_payload(self) -> dict:
|
||||
# ---------- Вспомогательные методы для аватара ----------
|
||||
|
||||
@staticmethod
|
||||
def _guess_mime(upload: UploadedFile, content: bytes) -> str:
|
||||
"""Определяет MIME-тип изображения.
|
||||
|
||||
Args:
|
||||
upload: Загруженный файл (может содержать content_type).
|
||||
content: Байты файла.
|
||||
|
||||
Returns:
|
||||
str: MIME-тип, например 'image/jpeg'.
|
||||
"""
|
||||
Преобразует данные формы в payload для POST /api/v1/auth/register/
|
||||
Ключи ориентированы на API-сериализатор (password — из password1).
|
||||
# 1) используем content_type, если его дал браузер/клиент
|
||||
ct = getattr(upload, "content_type", None)
|
||||
if ct and ct.startswith("image/"):
|
||||
return ct
|
||||
|
||||
# 2) пробуем Pillow
|
||||
if Image is not None:
|
||||
try:
|
||||
im = Image.open(upload) # type: ignore[arg-type]
|
||||
fmt = (im.format or "").upper()
|
||||
mime = Image.MIME.get(fmt)
|
||||
if mime:
|
||||
# откатим указатель, чтобы не ломать повторные чтения
|
||||
try:
|
||||
upload.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
return mime
|
||||
except Exception:
|
||||
try:
|
||||
upload.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) дефолт
|
||||
return "image/jpeg"
|
||||
|
||||
@staticmethod
|
||||
def _file_to_data_uri(upload: UploadedFile) -> Optional[str]:
|
||||
"""Кодирует изображение в data URI (base64).
|
||||
|
||||
Args:
|
||||
upload: Загруженный файл.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Строка вида 'data:<mime>;base64,<payload>' или None, если нет данных.
|
||||
|
||||
Raises:
|
||||
ValueError: Если файл нельзя прочитать.
|
||||
"""
|
||||
cd = self.cleaned_data
|
||||
payload = {
|
||||
if not upload:
|
||||
return None
|
||||
try:
|
||||
content: bytes = upload.read()
|
||||
try:
|
||||
upload.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise ValueError("Не удалось прочитать файл аватара.") from exc
|
||||
|
||||
if not content:
|
||||
return None
|
||||
|
||||
mime = RegistrationForm._guess_mime(upload, content)
|
||||
b64 = base64.b64encode(content).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
# ---------- Публичные методы ----------
|
||||
|
||||
def to_api_payload(self) -> Dict[str, Any]:
|
||||
"""Готовит JSON payload для POST `/api/v1/auth/register/`.
|
||||
|
||||
Включает:
|
||||
- все обязательные поля;
|
||||
- контакты (как пустые строки, если не указаны);
|
||||
- дополнительные поля (если заданы);
|
||||
- `avatar` как data URI (base64), если файл загружен.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Готовый словарь.
|
||||
|
||||
Raises:
|
||||
KeyError: Если метод вызван до `is_valid()` и ключей нет в `cleaned_data`.
|
||||
ValueError: При проблеме чтения файла аватара.
|
||||
"""
|
||||
cd = self.cleaned_data # предполагается, что форма валидна
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"first_name": cd["first_name"],
|
||||
"last_name": cd["last_name"],
|
||||
"phone_number": cd["phone_number"],
|
||||
@ -102,37 +236,27 @@ class RegistrationForm(forms.Form):
|
||||
"whatsapp": cd.get("whatsapp") or "",
|
||||
"wechat": cd.get("wechat") or "",
|
||||
}
|
||||
# доп. поля, если заданы
|
||||
|
||||
# Доп. поля
|
||||
if cd.get("patronymic"):
|
||||
payload["patronymic"] = cd["patronymic"]
|
||||
if cd.get("gender") is not None:
|
||||
payload["gender"] = cd["gender"]
|
||||
if cd.get("position") is not None:
|
||||
payload["position"] = cd["position"]
|
||||
|
||||
ids = self.skills_ids()
|
||||
if ids:
|
||||
payload["skills"] = ids
|
||||
return payload
|
||||
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Прямое создание пользователя (если не используешь API-прокси).
|
||||
Для фронта обычно лучше отправлять payload в API,
|
||||
но метод оставлен на случай локальной регистрации.
|
||||
"""
|
||||
data = self.cleaned_data
|
||||
user = Employee.objects.create_user(
|
||||
email=data["email"].strip().lower(),
|
||||
password=data["password1"],
|
||||
phone_number=data["phone_number"],
|
||||
first_name=data["first_name"],
|
||||
last_name=data["last_name"],
|
||||
birth_date=data.get("birth_date"),
|
||||
telegram=data.get("telegram", ""),
|
||||
whatsapp=data.get("whatsapp", ""),
|
||||
wechat=data.get("wechat", ""),
|
||||
)
|
||||
return user
|
||||
# Аватар → base64 data URI (совместимо с Base64ImageField)
|
||||
avatar_file = cd.get("avatar")
|
||||
if avatar_file:
|
||||
data_uri = self._file_to_data_uri(avatar_file)
|
||||
if data_uri:
|
||||
payload["avatar"] = data_uri
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class VerifyEmailForm(forms.Form):
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# backend/employees/urls_front.py
|
||||
from django.urls import path
|
||||
|
||||
from . import views_front
|
||||
|
||||
app_name = "employees"
|
||||
@ -13,6 +14,7 @@ urlpatterns = [
|
||||
path("<int:pk>/edit/", views_front.employee_edit, name="employee_edit"),
|
||||
path("create/", views_front.employee_create, name="employee_create"),
|
||||
# --- Отделы ---
|
||||
path("departments/create/", views_front.department_create, name="department_create"),
|
||||
path(
|
||||
"departments/<int:pk>/roles/edit/",
|
||||
views_front.edit_department_role,
|
||||
|
||||
@ -196,7 +196,7 @@ class ConfirmLogoutView(LoginRequiredMixin, View):
|
||||
|
||||
|
||||
class PasswordResetView(DjangoPasswordResetView):
|
||||
template_name = "auth/password_reset_form.html"
|
||||
template_name = "auth/password_reset.html"
|
||||
email_template_name = "auth/password_reset_email.txt"
|
||||
subject_template_name = "auth/password_reset_subject.txt"
|
||||
success_url = "/auth/password-reset/done/"
|
||||
|
||||
@ -561,7 +561,7 @@ def department_detail(request, pk: int):
|
||||
"can_manage": bool(perms.get("can_manage")),
|
||||
"can_change_head": bool(perms.get("can_change_head")),
|
||||
"can_assign_roles": bool(perms.get("can_assign_roles")),
|
||||
"posts": _fetch_department_posts(api, pk)
|
||||
"posts": _fetch_department_posts(api, pk),
|
||||
}
|
||||
)
|
||||
return render(request, "employees/department_detail.html", ctx)
|
||||
@ -596,6 +596,111 @@ def department_edit(request, pk: int):
|
||||
return redirect("employees:department_detail", pk=pk)
|
||||
|
||||
|
||||
# ---------- CREATE (staff) ----------
|
||||
@login_required
|
||||
@require_api_auth
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def department_create(request):
|
||||
"""
|
||||
Страница создания отдела.
|
||||
Работает ТОЛЬКО через API: POST v1/departments/
|
||||
Доступ: суперпользователь/страфф или обладатели perms.employees.add_department.
|
||||
"""
|
||||
# Мягкая проверка прав на фронте (API всё равно откажет 403 при отсутствии прав)
|
||||
if not (
|
||||
request.user.is_superuser
|
||||
or request.user.is_staff
|
||||
or request.user.has_perm("employees.add_department")
|
||||
):
|
||||
return redirect("employees:department_list")
|
||||
|
||||
if request.method == "GET":
|
||||
form = DepartmentEditForm()
|
||||
return render(
|
||||
request,
|
||||
"employees/department_create.html",
|
||||
{
|
||||
"form": form,
|
||||
"page_title": "Создать отдел",
|
||||
"submit_label": "Создать",
|
||||
"object": None,
|
||||
},
|
||||
)
|
||||
|
||||
# POST
|
||||
form = DepartmentEditForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return render(
|
||||
request,
|
||||
"employees/department_create.html",
|
||||
{
|
||||
"form": form,
|
||||
"page_title": "Создать отдел",
|
||||
"submit_label": "Создать",
|
||||
"object": None,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
api = get_api_client(request)
|
||||
payload = {
|
||||
"name": form.cleaned_data["name"],
|
||||
"description": form.cleaned_data.get("description") or "",
|
||||
}
|
||||
|
||||
# Отправляем в API
|
||||
r = api.post("v1/departments/", json=payload)
|
||||
if r.ok and isinstance(r.json, dict):
|
||||
dept_id = r.json.get("id")
|
||||
messages.success(request, "Отдел успешно создан.")
|
||||
if dept_id:
|
||||
return redirect("employees:department_detail", pk=dept_id)
|
||||
# если по какой-то причине id не вернули — уходим в список
|
||||
return redirect("employees:department_list")
|
||||
|
||||
# Обработка ошибок API: 400 — ошибки сериализатора, 403 — запрет
|
||||
try:
|
||||
err = r.json()
|
||||
except Exception:
|
||||
err = None
|
||||
|
||||
if r.status_code == 400 and isinstance(err, dict):
|
||||
# Пытаемся сопоставить ошибки полям формы
|
||||
attached = False
|
||||
for field in ("name", "description", "non_field_errors"):
|
||||
if field in err:
|
||||
msgs = err[field]
|
||||
if isinstance(msgs, (list, tuple)) and msgs:
|
||||
if field in form.fields:
|
||||
form.add_error(field, msgs[0])
|
||||
else:
|
||||
form.add_error(None, msgs[0])
|
||||
attached = True
|
||||
if not attached:
|
||||
form.add_error(
|
||||
None, "Не удалось создать отдел. Проверьте введённые данные."
|
||||
)
|
||||
status_code = 400
|
||||
elif r.status_code == 403:
|
||||
form.add_error(None, "Недостаточно прав для создания отдела.")
|
||||
status_code = 403
|
||||
else:
|
||||
form.add_error(None, f"Ошибка API ({r.status_code}). Повторите попытку позже.")
|
||||
status_code = 502
|
||||
|
||||
return render(
|
||||
request,
|
||||
"employees/department_create.html",
|
||||
{
|
||||
"form": form,
|
||||
"page_title": "Создать отдел",
|
||||
"submit_label": "Создать",
|
||||
"object": None,
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
|
||||
# ---------- SET HEAD ----------
|
||||
@require_api_auth
|
||||
@require_http_methods(["POST"])
|
||||
|
||||
@ -12,6 +12,7 @@ class AuthRequiredMiddleware:
|
||||
# Префиксы, которые всегда пропускаем (настройка через settings при желании)
|
||||
EXEMPT_PREFIXES = (
|
||||
"/api/",
|
||||
"/auth/reset/",
|
||||
# "/static/", # статика
|
||||
# "/media/", # медиа
|
||||
# "/__debug__/", # django-debug-toolbar, если используется
|
||||
@ -20,9 +21,12 @@ class AuthRequiredMiddleware:
|
||||
# Именованные маршруты, которые можно посещать анонимно (точные пути)
|
||||
EXEMPT_NAMES = (
|
||||
"auth_front:login",
|
||||
"auth_front:password_reset",
|
||||
"auth_front:register",
|
||||
"auth_front:verify_email",
|
||||
"auth_front:resend_email",
|
||||
"auth_front:password_reset_done",
|
||||
|
||||
"auth_front"
|
||||
)
|
||||
|
||||
|
||||
@ -3,6 +3,21 @@
|
||||
|
||||
{% block title %}Авторизация{% endblock %}
|
||||
|
||||
{# ВАЖНО: применяем тему ещё в <head>, чтобы не было «мигания» #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
@ -14,7 +29,6 @@
|
||||
<form method="post" action="{% url 'auth_front:login' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Общие ошибки формы -->
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4">
|
||||
{% for error in form.non_field_errors %}
|
||||
@ -24,16 +38,13 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Поле номера телефона -->
|
||||
<div class="mb-4 form-floating">
|
||||
<input type="tel"
|
||||
name="username"
|
||||
class="form-control {% if form.username.errors %}is-invalid{% endif %}"
|
||||
id="id_username"
|
||||
|
||||
id="id_username"
|
||||
value="{{ form.username.value|default:'' }}"
|
||||
required
|
||||
autofocus>
|
||||
required autofocus>
|
||||
<label for="id_username">Номер телефона или email</label>
|
||||
{% if form.username.errors %}
|
||||
<div class="invalid-feedback">
|
||||
@ -42,7 +53,6 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Поле пароля -->
|
||||
<div class="mb-4 form-floating position-relative">
|
||||
<input type="password"
|
||||
name="password"
|
||||
@ -76,7 +86,6 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Переключение видимости пароля
|
||||
document.querySelectorAll('.password-toggle').forEach(icon => {
|
||||
icon.addEventListener('click', function() {
|
||||
const input = this.closest('.form-floating').querySelector('input');
|
||||
@ -85,8 +94,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
this.classList.toggle('bi-eye');
|
||||
});
|
||||
});
|
||||
|
||||
// Автофокус на поле логина
|
||||
document.getElementById('id_username')?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Пароль изменен{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h3 class="mb-0">Успешная смена пароля</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-check-circle-fill text-success display-4 mb-4"></i>
|
||||
<p class="lead">Ваш пароль был успешно изменен!</p>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-primary">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Войти с новым паролем
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -3,28 +3,80 @@
|
||||
|
||||
{% block title %}Сброс пароля{% endblock %}
|
||||
|
||||
{# Раннее применение темы, синхронно с переключателем в base.html #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Сброс пароля</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.email }}
|
||||
<label for="{{ form.email.id_for_label }}">Email</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-envelope-arrow-up me-2"></i>Отправить инструкции
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Сброс пароля</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{# Общие ошибки формы #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||
{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-4 form-floating">
|
||||
<input
|
||||
type="email"
|
||||
name="{{ form.email.html_name }}"
|
||||
id="{{ form.email.auto_id|default:'id_email' }}"
|
||||
class="form-control {% if form.email.errors %}is-invalid{% endif %}"
|
||||
placeholder="Email"
|
||||
value="{{ form.email.value|default:'' }}"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
>
|
||||
<label for="{{ form.email.auto_id|default:'id_email' }}">Email</label>
|
||||
{% if form.email.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.email.errors|striptags }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-text">Мы отправим инструкции по сбросу на этот адрес.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-envelope-arrow-up me-2"></i>Отправить инструкции
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.getElementById('{{ form.email.auto_id|default:"id_email" }}')?.focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,21 +3,36 @@
|
||||
|
||||
{% block title %}Пароль восстановлен{% endblock %}
|
||||
|
||||
{# Раннее применение темы — синхронно с переключателем из base.html #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h3 class="mb-0">Пароль успешно изменен</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shield-check text-success display-4 mb-4"></i>
|
||||
<p class="lead">Ваш новый пароль был установлен. Теперь вы можете войти в систему.</p>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Войти в систему
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Пароль успешно изменён</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shield-check-fill display-4 mb-4 text-primary"></i>
|
||||
<p class="lead">Ваш новый пароль установлен. Теперь вы можете войти в систему.</p>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Войти в систему
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,34 +3,112 @@
|
||||
|
||||
{% block title %}Новый пароль{% endblock %}
|
||||
|
||||
{# Раннее применение темы — синхронно с переключателем из base.html #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Установка нового пароля</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.new_password1 }}
|
||||
<label for="{{ form.new_password1.id_for_label }}">Новый пароль</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-floating">
|
||||
{{ form.new_password2 }}
|
||||
<label for="{{ form.new_password2.id_for_label }}">Подтвердите пароль</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-key me-2"></i>Установить пароль
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Установка нового пароля</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{# Общие ошибки формы #}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||
{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-4 form-floating position-relative">
|
||||
<input
|
||||
type="password"
|
||||
name="{{ form.new_password1.html_name }}"
|
||||
id="{{ form.new_password1.auto_id|default:'id_new_password1' }}"
|
||||
class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}"
|
||||
placeholder="Новый пароль"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<label for="{{ form.new_password1.auto_id|default:'id_new_password1' }}">Новый пароль</label>
|
||||
<i class="bi bi-eye-slash password-toggle position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
data-target="#{{ form.new_password1.auto_id|default:'id_new_password1' }}"
|
||||
style="cursor:pointer; z-index:5"></i>
|
||||
{% if form.new_password1.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.new_password1.errors|striptags }}
|
||||
</div>
|
||||
{% elif form.new_password1.help_text %}
|
||||
<div class="form-text mt-2">{{ form.new_password1.help_text|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 form-floating position-relative">
|
||||
<input
|
||||
type="password"
|
||||
name="{{ form.new_password2.html_name }}"
|
||||
id="{{ form.new_password2.auto_id|default:'id_new_password2' }}"
|
||||
class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}"
|
||||
placeholder="Подтвердите пароль"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
>
|
||||
<label for="{{ form.new_password2.auto_id|default:'id_new_password2' }}">Подтвердите пароль</label>
|
||||
<i class="bi bi-eye-slash password-toggle position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
data-target="#{{ form.new_password2.auto_id|default:'id_new_password2' }}"
|
||||
style="cursor:pointer; z-index:5"></i>
|
||||
{% if form.new_password2.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.new_password2.errors|striptags }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="bi bi-key me-2"></i>Установить пароль
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
// Переключение видимости пароля
|
||||
document.querySelectorAll('.password-toggle').forEach(icon => {
|
||||
icon.addEventListener('click', function(){
|
||||
const input = document.querySelector(this.getAttribute('data-target'));
|
||||
if (!input) return;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
this.classList.toggle('bi-eye');
|
||||
this.classList.toggle('bi-eye-slash');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -3,24 +3,39 @@
|
||||
|
||||
{% block title %}Письмо отправлено{% endblock %}
|
||||
|
||||
{# Раннее применение темы — синхронно с переключателем из base.html #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h3 class="mb-0">Инструкции отправлены</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-envelope-check-fill text-info display-4 mb-4"></i>
|
||||
<p class="lead">
|
||||
На ваш email отправлены инструкции по сбросу пароля.
|
||||
Проверьте почту и следуйте указаниям в письме.
|
||||
</p>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-primary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Вернуться к авторизации
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Инструкции отправлены</h3>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-envelope-check-fill display-4 mb-4 text-primary"></i>
|
||||
<p class="lead mb-3">
|
||||
На ваш email отправлены инструкции по сбросу пароля.
|
||||
Проверьте почту и следуйте указаниям в письме.
|
||||
</p>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-arrow-left me-2"></i>Вернуться к авторизации
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
Вы запросили сброс пароля на {{ site_name }}.
|
||||
|
||||
Перейдите по ссылке для смены пароля:
|
||||
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||
{{ protocol }}://{{ domain }}{% url 'auth_front:password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
Если вы не запрашивали сброс пароля, просто игнорируйте это письмо.
|
||||
|
||||
@ -1,127 +1,281 @@
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% load static django_bootstrap5 %}
|
||||
|
||||
{% block title %}Регистрация сотрудника{% endblock %}
|
||||
|
||||
{# ВАЖНО: применяем тему ещё в <head>, чтобы не было «мигания» темы #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto';
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Регистрация нового сотрудника</h3>
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow">
|
||||
{# Заголовок без жёсткой покраски — следуем теме #}
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h3 class="mb-0">Регистрация нового сотрудника</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Контакты -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.email show_label=False placeholder="E-mail" %}
|
||||
<small class="text-muted">Пример: ivanov@company.com</small>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Контакты -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.email show_label=False placeholder="E-mail" %}
|
||||
<small class="text-muted">Пример: ivanov@company.com</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мессенджеры (добавлен WeChat) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-whatsapp text-success"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-telegram text-primary"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.wechat show_label=False placeholder="WeChat" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-wechat" style="color: #07C160;"></i>
|
||||
</span>
|
||||
</div>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4" style="border-radius: 8px;">
|
||||
{% for error in form.non_field_errors %}
|
||||
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
|
||||
{% endfor %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ФИО + дата рождения -->
|
||||
<div class="row g-3 mb-4">
|
||||
{% for field in form %}
|
||||
{% if field.name == 'last_name' or field.name == 'first_name' or field.name == 'patronymic' %}
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field field show_label=False %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.birth_date %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Пароли -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password1.auto_id }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password2.auto_id }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Аватар -->
|
||||
<div class="mb-4">
|
||||
{% bootstrap_field form.avatar %}
|
||||
<small class="text-muted">Рекомендуемый размер: 200x200 px</small>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-person-plus me-2"></i>Зарегистрироваться
|
||||
</button>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-link">Уже есть аккаунт? Войти</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="col-md-6">
|
||||
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мессенджеры -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-whatsapp text-success"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-telegram"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.wechat show_label=False placeholder="WeChat" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-wechat" style="color:#07C160;"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-0 mt-3" style="border-radius:8px;">
|
||||
{% for error in form.non_field_errors %}
|
||||
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
|
||||
{% endfor %}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ФИО -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">{% bootstrap_field form.last_name show_label=False placeholder="Фамилия" %}</div>
|
||||
<div class="col-md-4">{% bootstrap_field form.first_name show_label=False placeholder="Имя" %}</div>
|
||||
<div class="col-md-4">{% bootstrap_field form.patronymic show_label=False placeholder="Отчество" %}</div>
|
||||
</div>
|
||||
|
||||
<!-- Дата рождения + Пол -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label d-block mb-2">Пол</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-m" value="1">
|
||||
<label class="form-check-label" for="gender-m">Мужской</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-f" value="2">
|
||||
<label class="form-check-label" for="gender-f">Женский</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="gender" id="gender-u" value="0" checked>
|
||||
<label class="form-check-label" for="gender-u">Не указан</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Пароли -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password1.auto_id }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6 position-relative">
|
||||
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
|
||||
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password2.auto_id }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Аватар с превью -->
|
||||
<div class="row g-3 align-items-center mb-4">
|
||||
<div class="col-auto">
|
||||
<div class="rounded-circle border bg-body-secondary d-flex align-items-center justify-content-center"
|
||||
style="width:96px;height:96px;overflow:hidden;">
|
||||
<img id="avatarPreview" src=""
|
||||
alt="Превью аватара"
|
||||
class="d-none"
|
||||
style="width:100%;height:100%;object-fit:cover;">
|
||||
<i id="avatarPlaceholder" class="bi bi-person-circle fs-1 text-secondary"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% bootstrap_field form.avatar %}
|
||||
<small class="text-muted">Рекомендуемый размер: 200×200 px</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-person-plus me-2"></i>Зарегистрироваться
|
||||
</button>
|
||||
<a href="{% url 'auth_front:login' %}" class="btn btn-link">Уже есть аккаунт? Войти</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.password-toggle').forEach(icon => {
|
||||
icon.addEventListener('click', function() {
|
||||
const input = document.querySelector(this.getAttribute('data-target'));
|
||||
if (input) {
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
this.classList.toggle('bi-eye');
|
||||
this.classList.toggle('bi-eye-slash');
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Переключение видимости пароля
|
||||
document.querySelectorAll('.password-toggle').forEach(icon => {
|
||||
icon.addEventListener('click', function(){
|
||||
const input = document.querySelector(this.getAttribute('data-target'));
|
||||
if (!input) return;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
this.classList.toggle('bi-eye');
|
||||
this.classList.toggle('bi-eye-slash');
|
||||
});
|
||||
});
|
||||
|
||||
// === Превью аватара ===
|
||||
const fileInput = document.querySelector('input[type="file"][name="{{ form.avatar.html_name }}"]');
|
||||
const preview = document.getElementById('avatarPreview');
|
||||
const placeholder = document.getElementById('avatarPlaceholder');
|
||||
const formEl = fileInput ? fileInput.closest('form') : null;
|
||||
|
||||
let objectURL = null;
|
||||
|
||||
function showPreview(url){
|
||||
if (!preview) return;
|
||||
// Скрыть плейсхолдер, показать картинку
|
||||
if (placeholder) placeholder.classList.add('d-none');
|
||||
preview.src = url;
|
||||
preview.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function hidePreview(){
|
||||
if (!preview) return;
|
||||
// Убрать картинку, показать плейсхолдер
|
||||
preview.removeAttribute('src');
|
||||
preview.classList.add('d-none');
|
||||
if (placeholder) placeholder.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function revokeObjectURLSafe(){
|
||||
try { if (objectURL) URL.revokeObjectURL(objectURL); } catch(_) {}
|
||||
objectURL = null;
|
||||
}
|
||||
|
||||
if (fileInput && preview){
|
||||
fileInput.addEventListener('change', function(){
|
||||
revokeObjectURLSafe();
|
||||
const file = this.files && this.files[0];
|
||||
if (file){
|
||||
objectURL = URL.createObjectURL(file);
|
||||
showPreview(objectURL);
|
||||
} else {
|
||||
hidePreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Сброс формы — вернуть плейсхолдер
|
||||
if (formEl){
|
||||
formEl.addEventListener('reset', function(){
|
||||
revokeObjectURLSafe();
|
||||
// Небольшая задержка, чтобы браузер успел очистить value у input[type=file]
|
||||
setTimeout(hidePreview, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Освобождение URL при уходе со страницы
|
||||
window.addEventListener('beforeunload', revokeObjectURLSafe);
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Навешиваемся на все инпуты, помеченные data-phone-only
|
||||
const phoneInputs = document.querySelectorAll('input[data-phone-only]');
|
||||
const PHONE_RE = /^\+?\d{10,15}$/;
|
||||
|
||||
function sanitizePhone(v){
|
||||
v = String(v || '');
|
||||
let out = '';
|
||||
for (let i=0;i<v.length;i++){
|
||||
const ch = v[i];
|
||||
if (ch >= '0' && ch <= '9') { out += ch; continue; }
|
||||
if (ch === '+' && out.length === 0) { out = '+'; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validate(el){
|
||||
const v = el.value;
|
||||
const ok = v === '' || PHONE_RE.test(v);
|
||||
el.setCustomValidity(ok ? '' : 'Введите номер телефона в формате +79991234567 (10–15 цифр).');
|
||||
}
|
||||
|
||||
phoneInputs.forEach((el)=>{
|
||||
// Ввод
|
||||
el.addEventListener('input', function(){
|
||||
const prev = this.value;
|
||||
const cur = sanitizePhone(prev);
|
||||
if (prev !== cur){
|
||||
const pos = this.selectionStart || cur.length;
|
||||
this.value = cur;
|
||||
try { this.setSelectionRange(pos - (prev.length - cur.length), pos - (prev.length - cur.length)); } catch(_){}
|
||||
}
|
||||
validate(this);
|
||||
});
|
||||
|
||||
// Вставка (paste)
|
||||
el.addEventListener('paste', (e)=>{
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text');
|
||||
const cur = sanitizePhone(text);
|
||||
document.execCommand('insertText', false, cur);
|
||||
});
|
||||
|
||||
// Потеря фокуса — финальная проверка
|
||||
el.addEventListener('blur', function(){
|
||||
this.value = sanitizePhone(this.value);
|
||||
validate(this);
|
||||
});
|
||||
|
||||
// Инициализация
|
||||
el.value = sanitizePhone(el.value);
|
||||
validate(el);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -2,6 +2,21 @@
|
||||
|
||||
{% block title %}Подтверждение email — EUSRR{% endblock %}
|
||||
|
||||
{# ВАЖНО: применяем тему ещё в <head>, до рендера страницы #}
|
||||
{% block extra_css %}
|
||||
<script>
|
||||
(function(){
|
||||
const root = document.documentElement;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getSaved = () => localStorage.getItem('theme') || 'auto'; // 'light' | 'dark' | 'auto'
|
||||
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
|
||||
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
|
||||
apply();
|
||||
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 520px;">
|
||||
{% if messages %}
|
||||
@ -67,8 +82,7 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const el = document.getElementById('id_code');
|
||||
if (el) el.focus();
|
||||
document.getElementById('id_code')?.focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
<!-- templates/calendar/calendar.html -->
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div id='calendar'></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet" />
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let calendarEl = document.getElementById('calendar');
|
||||
let calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
events: '/calendar/api/events/',
|
||||
eventDidMount: function(info) {
|
||||
// Можно пометить ежегодные события специальным цветом
|
||||
if (info.event.extendedProps.recurrence === 'annual') {
|
||||
info.el.style.borderColor = 'green';
|
||||
}
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
13
backend/templates/emails/password_reset_email.html
Normal file
13
backend/templates/emails/password_reset_email.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<body style="font-family:Arial,Helvetica,sans-serif; line-height:1.5; color:#111;">
|
||||
<h2>Сброс пароля</h2>
|
||||
<p>Здравствуйте {{ user.get_full_name|default_if_none:"" }}!</p>
|
||||
<p>Перейдите по ссылке для смены пароля:</p>
|
||||
<div style="font-size:24px; font-weight:700; letter-spacing:4px; padding:12px 0;">
|
||||
{{ protocol }}://{{ domain }}{% url 'auth_front:password_reset_confirm' uidb64=uid token=token %}
|
||||
</div>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:16px 0;">
|
||||
<p style="font-size:12px; color:#666;">Если вы не запрашивали сброс пароля, просто игнорируйте это письмо.</p>
|
||||
</body>
|
||||
</html>
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{ width:100%; height:40px; padding:8px 38px; border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%); font-size:15px; outline:none; transition: box-shadow .2s, border-color .2s, background .2s; }
|
||||
[data-bs-theme="dark"] .ios-search-input{ background: color-mix(in srgb, var(--bs-body-bg), #fff 6%); }
|
||||
@ -13,12 +13,20 @@
|
||||
.ios-search > .bi-search{ position:absolute; left:10px; top:50%; transform:translateY(-50%); opacity:.55; pointer-events:none; font-size:.95rem; }
|
||||
.ios-search-clear{ position:absolute; right:4px; top:50%; transform:translateY(-50%); border:0; background:transparent; padding:6px; display:none; }
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
|
||||
/* список — единственный прокручиваемый блок */
|
||||
#deptList{
|
||||
max-height: calc(100dvh - var(--navbar-h) - 160px);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#deptList .dept-row{ padding:.6rem .75rem; }
|
||||
#deptList .dept-row + .dept-row{ border-top:1px solid var(--bs-border-color); }
|
||||
.feed-author{ font-weight:700; }
|
||||
.feed-sub{ color:var(--bs-secondary-color); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -29,7 +37,7 @@
|
||||
<div class="feed-header d-flex align-items-center">
|
||||
<i class="bi-diagram-3 fs-2 text-primary"></i>
|
||||
<h2 class="title mb-0">Отделы</h2>
|
||||
|
||||
<span class="ms-2 small text-secondary">Всего: {{ departments|length }}</span>
|
||||
<div class="ms-auto ios-search">
|
||||
<div class="ios-search">
|
||||
<i class="bi-search"></i>
|
||||
@ -41,7 +49,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create_departments or perms.employees.add_department or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="feed-card mb-2">
|
||||
<a href="{% url 'employees:department_create' %}"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none"
|
||||
role="button" aria-label="Создать отдел">
|
||||
<span class="feed-author">Создать отдел</span>
|
||||
<i class="bi-plus-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="feed-card">
|
||||
<div id="deptList" class="py-1">
|
||||
{# поддержка обоих форматов: dict(results) или list #}
|
||||
@ -95,24 +112,70 @@
|
||||
const input = document.getElementById('deptFilter');
|
||||
const clearBtn = document.querySelector('.ios-search-clear');
|
||||
const list = document.getElementById('deptList');
|
||||
const rows = () => list.querySelectorAll('.dept-row');
|
||||
const totalEl = document.getElementById('empTotal');
|
||||
const totalEmpEl = document.getElementById('empTotal');
|
||||
|
||||
function apply(){
|
||||
const q = (input?.value || '').trim().toLowerCase();
|
||||
let total = 0;
|
||||
rows().forEach(r => {
|
||||
const hay = (r.getAttribute('data-haystack') || '').toLowerCase();
|
||||
const show = !q || hay.includes(q);
|
||||
r.style.display = show ? '' : 'none';
|
||||
if (show) total += (parseInt(r.getAttribute('data-emp')) || 0);
|
||||
});
|
||||
if (totalEl) totalEl.textContent = String(total);
|
||||
// (опционально) счётчик отделов
|
||||
const totalDeptBadge = document.querySelector('.title ~ .ms-2.small span'); // "Всего: {{ departments|length }}"
|
||||
const allRows = Array.from(list.querySelectorAll('.dept-row'));
|
||||
|
||||
// сообщение "нет результатов"
|
||||
let emptyNotice = document.getElementById('deptEmptyNotice');
|
||||
if (!emptyNotice){
|
||||
emptyNotice = document.createElement('div');
|
||||
emptyNotice.id = 'deptEmptyNotice';
|
||||
emptyNotice.className = 'p-3 text-secondary d-none';
|
||||
emptyNotice.textContent = 'Ничего не найдено.';
|
||||
list.appendChild(emptyNotice);
|
||||
}
|
||||
|
||||
function showRow(row){
|
||||
// вернём normal display и d-flex
|
||||
row.style.removeProperty('display');
|
||||
if (!row.classList.contains('d-flex')) row.classList.add('d-flex');
|
||||
}
|
||||
function hideRow(row){
|
||||
// снимем d-flex (чтобы не конкурировал) и зададим inline !important
|
||||
row.classList.remove('d-flex');
|
||||
row.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
|
||||
function normalize(s){
|
||||
return (s || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function apply(){
|
||||
const q = normalize(input?.value);
|
||||
let totalEmp = 0;
|
||||
let shown = 0;
|
||||
|
||||
for (const r of allRows){
|
||||
const hay = normalize(r.getAttribute('data-haystack'));
|
||||
const match = !q || hay.includes(q);
|
||||
|
||||
if (match){
|
||||
showRow(r);
|
||||
shown += 1;
|
||||
totalEmp += (parseInt(r.getAttribute('data-emp')) || 0);
|
||||
} else {
|
||||
hideRow(r);
|
||||
}
|
||||
}
|
||||
|
||||
// переключаем "нет результатов"
|
||||
emptyNotice.classList.toggle('d-none', shown > 0);
|
||||
|
||||
// обновляем счетчики
|
||||
if (totalEmpEl) totalEmpEl.textContent = String(totalEmp);
|
||||
if (totalDeptBadge) totalDeptBadge.textContent = String(shown);
|
||||
}
|
||||
|
||||
// первичная инициализация
|
||||
apply();
|
||||
|
||||
// события
|
||||
input?.addEventListener('input', apply);
|
||||
clearBtn?.addEventListener('click', () => { input.value=''; input.focus(); apply(); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -133,7 +133,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.employees.add_employee or request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="feed-card mb-2">
|
||||
<a href="{% url 'employees:employee_create' %}"
|
||||
class="dept-row d-flex w-100 justify-content-center align-items-center gap-2 text-decoration-none"
|
||||
role="button" aria-label="Создать отдел">
|
||||
<span class="feed-author">Создать профиль</span>
|
||||
<i class="bi-plus-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="empList" class="feed-stream">
|
||||
{% if employees %}
|
||||
{% for emp in employees %}
|
||||
|
||||
@ -113,6 +113,25 @@
|
||||
#rightbarOffcanvas .week-vertical{ flex: 0 0 auto; }
|
||||
|
||||
.calendar-fab{ position: fixed; right: 1rem; bottom: 1rem; z-index: 1060; width: 52px; height: 52px; border-radius: 9999px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
|
||||
|
||||
/* ====== Color picker (palette) ====== */
|
||||
.color-swatch{
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
.color-swatch.active{
|
||||
outline: 2px solid var(--bs-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* ====== Event details modal ====== */
|
||||
.color-dot{
|
||||
width: 12px; height: 12px; border-radius: 9999px; display: inline-block;
|
||||
vertical-align: middle; margin-right: 6px; border: 1px solid #00000022;
|
||||
}
|
||||
</style>
|
||||
|
||||
{# десктопная правая колонка #}
|
||||
@ -194,6 +213,10 @@
|
||||
<label class="form-label">Заголовок</label>
|
||||
<input type="text" class="form-control" name="title" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="eventDescription">Описание</label>
|
||||
<textarea class="form-control" id="eventDescription" name="description" rows="3" ></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-6">
|
||||
@ -277,7 +300,19 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Цвет</label>
|
||||
<input type="text" class="form-control" name="color" placeholder="#RRGGBB">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="color" class="form-control form-control-color" name="color" id="eventColorInput" value="#0d6efd" title="Выберите цвет">
|
||||
<div class="d-flex flex-wrap gap-1" id="colorPalette" aria-label="Быстрый выбор цвета">
|
||||
<button type="button" class="color-swatch" data-color="#0d6efd" style="background-color:#0d6efd" title="Синий"></button>
|
||||
<button type="button" class="color-swatch" data-color="#198754" style="background-color:#198754" title="Зелёный"></button>
|
||||
<button type="button" class="color-swatch" data-color="#dc3545" style="background-color:#dc3545" title="Красный"></button>
|
||||
<button type="button" class="color-swatch" data-color="#fd7e14" style="background-color:#fd7e14" title="Оранжевый"></button>
|
||||
<button type="button" class="color-swatch" data-color="#ffc107" style="background-color:#ffc107" title="Жёлтый"></button>
|
||||
<button type="button" class="color-swatch" data-color="#0dcaf0" style="background-color:#0dcaf0" title="Голубой"></button>
|
||||
<button type="button" class="color-swatch" data-color="#6f42c1" style="background-color:#6f42c1" title="Фиолетовый"></button>
|
||||
<button type="button" class="color-swatch" data-color="#212529" style="background-color:#212529" title="Чёрный"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Локация</label>
|
||||
@ -291,17 +326,52 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Modal: Просмотр события ===== -->
|
||||
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title d-flex align-items-center gap-2">
|
||||
<span id="detailColorDot" class="color-dot"></span>
|
||||
<span id="detailTitle">Событие</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4">Когда</dt>
|
||||
<dd class="col-8" id="detailWhen">—</dd>
|
||||
<dt class="col-4">Где</dt>
|
||||
<dd class="col-8" id="detailLocation">—</dd>
|
||||
<dt class="col-4">Описание</dt>
|
||||
<dd class="col-8" id="detailDescription">—</dd>
|
||||
<dt class="col-4">Контекст</dt>
|
||||
<dd class="col-8" id="detailScope">—</dd>
|
||||
<dt class="col-4">Повторение</dt>
|
||||
<dd class="col-8" id="detailRecurrence">—</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if (!window.FullCalendar) return;
|
||||
|
||||
|
||||
initColorPicker();
|
||||
|
||||
|
||||
/* ===== токен из meta ===== */
|
||||
function getAccessToken(){
|
||||
const meta = document.querySelector('meta[name="api-access"]');
|
||||
@ -309,9 +379,12 @@
|
||||
if (m) return m.trim();
|
||||
try{ return localStorage.getItem('api.access') || ''; }catch(_){ return ''; }
|
||||
}
|
||||
|
||||
// Сохраняем токен глобально при загрузке, чтобы избежать timing issues
|
||||
const globalToken = getAccessToken();
|
||||
|
||||
function authHeaders(){
|
||||
const t = getAccessToken();
|
||||
return t ? { 'Authorization': 'Bearer ' + t } : {};
|
||||
return globalToken ? { 'Authorization': 'Bearer ' + globalToken } : {};
|
||||
}
|
||||
|
||||
/* ===== endpoints (единый вьюсет) ===== */
|
||||
@ -321,6 +394,7 @@
|
||||
|
||||
/* ===== только ЧИСЛОВОЙ PK отдела ===== */
|
||||
const DIGITS_RE = /^\d+$/;
|
||||
const DEFAULT_EVENT_COLOR = '#0d6efd';
|
||||
function extractNumericPk(d){
|
||||
const cands = [d?.pk, d?.id, d?.department_id];
|
||||
for (const v of cands){
|
||||
@ -373,6 +447,8 @@
|
||||
/* ===== состояние ===== */
|
||||
let departments = []; // [{id, name}] где id — всегда числовой PK
|
||||
const state = { type: 'company', deptId: null };
|
||||
// защита от гонок при загрузке отделов
|
||||
let deptsLoadSeq = 0;
|
||||
|
||||
/* ===== utils ===== */
|
||||
const dayMs = 86400000, hourMs = 3600000;
|
||||
@ -392,9 +468,14 @@
|
||||
const startOfWeek = d => { const x=new Date(d.getFullYear(),d.getMonth(),d.getDate()); const day=(x.getDay()+6)%7; x.setDate(x.getDate()-day); x.setHours(0,0,0,0); return x; };
|
||||
const endOfWeek = d => { const s=startOfWeek(d); const e=new Date(s); e.setDate(s.getDate()+7); return e; };
|
||||
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
|
||||
const truncate = (s, n=20) => {
|
||||
const t = (s ?? '').toString();
|
||||
return t.length > n ? t.slice(0, n - 1) + '…' : t;
|
||||
};
|
||||
|
||||
async function fetchJSON(url, opts){
|
||||
const r = await fetch(url, { headers: { 'Accept':'application/json', ...authHeaders(), ...(opts?.headers||{}) }, ...(opts||{}) });
|
||||
async function fetchJSON(url, opts = {}){
|
||||
const headers = { 'Accept':'application/json', ...authHeaders(), ...(opts.headers || {}) };
|
||||
const r = await fetch(url, { headers, ...opts });
|
||||
if (r.status === 401) { console.warn('401 от API — нужен валидный access токен'); return []; }
|
||||
if (!r.ok) {
|
||||
let text = await r.text();
|
||||
@ -404,39 +485,141 @@
|
||||
const data = await r.json();
|
||||
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
|
||||
}
|
||||
function setWeekdaysFromMask(mask){
|
||||
try{
|
||||
const m = Number(mask) || 0;
|
||||
for (let i=0;i<=6;i++){
|
||||
const el = document.getElementById('wd'+i);
|
||||
if (el) el.checked = !!(m & (1<<i));
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== helpers для форматирования и API детальной карточки ===== */
|
||||
function pad(n){ return (n<10?'0':'') + n; }
|
||||
function fmtDate(d){ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||
function fmtTime(d){ return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
|
||||
function fmtWhen(ev){
|
||||
if (!ev) return '—';
|
||||
const allDay = !!ev.all_day;
|
||||
// аккуратно парсим дату/дату-время (без UTC-сдвигов на простых датах)
|
||||
const sd = toDate(ev.start || ev.start_date);
|
||||
const ed = (ev.end || ev.end_date) ? toDate(ev.end || ev.end_date) : null;
|
||||
if (allDay){
|
||||
if (ed && fmtDate(sd) !== fmtDate(ed)) return `${fmtDate(sd)} — ${fmtDate(ed)} (весь день)`;
|
||||
return `${fmtDate(sd)} (весь день)`;
|
||||
}
|
||||
if (ed) return `${fmtDate(sd)} ${fmtTime(sd)} — ${fmtDate(ed)} ${fmtTime(ed)}`;
|
||||
return `${fmtDate(sd)} ${fmtTime(sd)}`;
|
||||
}
|
||||
async function apiGet(url){
|
||||
const r = await fetch(url, { headers: { 'Accept': 'application/json', ...authHeaders() } });
|
||||
const data = await r.json().catch(()=>null);
|
||||
if (!r.ok){ const err=new Error('GET failed'); err.status=r.status; err.data=data; throw err; }
|
||||
return data;
|
||||
}
|
||||
async function apiDelete(url){
|
||||
const r = await fetch(url, { method:'DELETE', headers: { ...authHeaders() } });
|
||||
if (!r.ok){ const data = await r.json().catch(()=>null); const err=new Error('DELETE failed'); err.status=r.status; err.data=data; throw err; }
|
||||
}
|
||||
// узлы модала (если разметка подключена)
|
||||
const detailsModalEl = document.getElementById('eventDetailsModal');
|
||||
// Вынесем модал в <body>, иначе позиционирование ломается, если родитель с transform
|
||||
if (detailsModalEl && detailsModalEl.parentElement !== document.body) {
|
||||
document.body.appendChild(detailsModalEl);
|
||||
}
|
||||
// Гарантируем вертикальное центрирование (если в HTML забыли класс)
|
||||
detailsModalEl?.querySelector('.modal-dialog')?.classList.add('modal-dialog-centered');
|
||||
|
||||
const detailsModal = detailsModalEl ? new bootstrap.Modal(detailsModalEl) : null;
|
||||
|
||||
const $dt = {
|
||||
title: document.getElementById('detailTitle'),
|
||||
when: document.getElementById('detailWhen'),
|
||||
loc: document.getElementById('detailLocation'),
|
||||
desc: document.getElementById('detailDescription'),
|
||||
scope: document.getElementById('detailScope'),
|
||||
rec: document.getElementById('detailRecurrence'),
|
||||
dot: document.getElementById('detailColorDot'),
|
||||
btnEdit: document.getElementById('btnEditEvent'),
|
||||
btnDel: document.getElementById('btnDeleteEvent'),
|
||||
};
|
||||
let currentDetail = null;
|
||||
function fillDetails(ev){
|
||||
if (!$dt.title) return; // модала нет — тихо выходим
|
||||
$dt.title.textContent = ev.title || 'Событие';
|
||||
$dt.when.textContent = fmtWhen(ev);
|
||||
$dt.loc.textContent = ev.location || '—';
|
||||
$dt.desc.textContent = ev.description || '—';
|
||||
$dt.scope.textContent = ev.department ? `Отдел: ${ev.department.name || ev.department}` : 'Компания';
|
||||
$dt.rec.textContent = ev.recurrence_interval ? `${ev.recurrence} ×${ev.recurrence_interval}` : (ev.recurrence || '—');
|
||||
const col = ev.color || DEFAULT_EVENT_COLOR; if ($dt.dot) $dt.dot.style.backgroundColor = col;
|
||||
}
|
||||
async function openEventDetailsById(eventId){
|
||||
try{
|
||||
const data = await apiGet(`${API_EVENTS}${eventId}/`);
|
||||
currentDetail = data;
|
||||
fillDetails(data);
|
||||
detailsModal && detailsModal.show();
|
||||
}catch(err){
|
||||
console.error('Load details error', err);
|
||||
if (err.status === 403) alert('Недостаточно прав для просмотра деталей события.');
|
||||
else alert('Не удалось загрузить событие.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== загрузка отделов (только числовой PK) ===== */
|
||||
async function loadDepartments(){
|
||||
chooserMenu.querySelectorAll('[data-cal="dept"]').forEach(n => n.closest('li')?.remove());
|
||||
const mySeq = ++deptsLoadSeq; // метка версии вызова
|
||||
|
||||
let raw = [];
|
||||
try{ raw = await fetchJSON(API_MY_DEPTS); }catch(e){ raw = []; }
|
||||
|
||||
// маппинг → {id, name}
|
||||
let mapped = (Array.isArray(raw) ? raw : [])
|
||||
.map(d => {
|
||||
const pk = extractNumericPk(d);
|
||||
if (!pk) return null;
|
||||
const name = d.name ?? d.title ?? d.code ?? `Отдел #${pk}`;
|
||||
return { id: pk, name };
|
||||
return { id: String(pk), name };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// если API вернул пусто — fallback к data-dept-id
|
||||
if (!mapped.length && legacyDeptIds.length){
|
||||
mapped = legacyDeptIds.map(pk => ({ id: pk, name: `Отдел #${pk}` }));
|
||||
mapped = legacyDeptIds.map(pk => ({ id: String(pk), name: `Отдел #${pk}` }));
|
||||
}
|
||||
|
||||
departments = mapped;
|
||||
// ✅ дедуп по id
|
||||
const uniq = new Map();
|
||||
for (const d of mapped){
|
||||
const key = String(d.id);
|
||||
if (!uniq.has(key)) uniq.set(key, d);
|
||||
}
|
||||
const nextDepartments = Array.from(uniq.values());
|
||||
|
||||
// если наш вызов устарел (есть более поздний) — не трогаем DOM
|
||||
if (mySeq !== deptsLoadSeq) return departments;
|
||||
|
||||
departments = nextDepartments;
|
||||
|
||||
// очистка пунктов отделов (только сейчас, после защиты от гонки)
|
||||
chooserMenu.querySelectorAll('[data-cal="dept"]').forEach(n => n.closest('li')?.remove());
|
||||
|
||||
if (!departments.length){
|
||||
setChooserLabel();
|
||||
return;
|
||||
return departments;
|
||||
}
|
||||
|
||||
const divider = chooserMenu.querySelector('.dropdown-divider') || (() => {
|
||||
// убедимся, что есть разделитель
|
||||
chooserMenu.querySelector('.dropdown-divider') || (() => {
|
||||
const li = document.createElement('li'); li.innerHTML = '<hr class="dropdown-divider">';
|
||||
chooserMenu.appendChild(li); return li.querySelector('hr');
|
||||
chooserMenu.appendChild(li);
|
||||
})();
|
||||
|
||||
// вставка пунктов
|
||||
const frag = document.createDocumentFragment();
|
||||
departments.forEach(d => {
|
||||
const li = document.createElement('li');
|
||||
@ -445,11 +628,12 @@
|
||||
});
|
||||
chooserMenu.appendChild(frag);
|
||||
|
||||
// валидация текущего выбора
|
||||
if (state.type === 'dept' && !departments.some(d => String(d.id) === String(state.deptId))){
|
||||
state.type = 'company'; state.deptId = null;
|
||||
}
|
||||
|
||||
setChooserLabel();
|
||||
return departments;
|
||||
}
|
||||
|
||||
function currentDeptLabel(){
|
||||
@ -480,15 +664,43 @@
|
||||
});
|
||||
|
||||
/* ===== комбинированная загрузка событий (occurrences) ===== */
|
||||
// События для текущего контекста (оставляю — используется календарём)
|
||||
async function fetchEventsCombined(start, end){
|
||||
let base = eventsUrl(state.type === 'dept' ? state.deptId : null);
|
||||
const withRange = addRange(base, start, end);
|
||||
try{
|
||||
return await fetchJSON(withRange);
|
||||
}catch(e){
|
||||
console.error('Fetch events failed', e);
|
||||
return [];
|
||||
try{ return await fetchJSON(withRange); }
|
||||
catch(e){ console.error('Fetch events failed', e); return []; }
|
||||
}
|
||||
|
||||
// ✅ ВСЕ доступные календари: компания + все отделы пользователя
|
||||
async function fetchEventsAllCalendars(start, end){
|
||||
// при первом вызове убедимся, что отделы загружены
|
||||
if (!departments || departments.length === 0){
|
||||
try { await loadDepartments(); } catch(_) {}
|
||||
}
|
||||
// формируем список источников с подписью
|
||||
const sources = [
|
||||
{ url: addRange(eventsUrl(null), start, end), label: 'Компания', type: 'company', id: null },
|
||||
...departments.map(d => ({
|
||||
url: addRange(eventsUrl(d.id), start, end),
|
||||
label: d.name,
|
||||
type: 'dept',
|
||||
id: d.id
|
||||
})),
|
||||
];
|
||||
const chunks = await Promise.all(sources.map(s =>
|
||||
fetchJSON(s.url).then(arr => (arr || []).map(ev => ({ ...ev, __source: { label: s.label, type: s.type, id: s.id } })))
|
||||
.catch(()=>[])
|
||||
));
|
||||
const merged = chunks.flat();
|
||||
// дедуп по устойчивому ключу
|
||||
const seen = new Set(); const out = [];
|
||||
for (const ev of merged){
|
||||
const key = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id ?? null;
|
||||
const sk = key != null ? String(key) : null;
|
||||
if (!sk || !seen.has(sk)){ if (sk) seen.add(sk); out.push(ev); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeEvent(ev){
|
||||
@ -501,7 +713,10 @@
|
||||
if (!e) e = new Date(s.getTime() + (allDay ? dayMs : hourMs));
|
||||
const color = ev.color || ev.bgColor || null;
|
||||
const recurrence = ev.recurrence || ev.recurrence_display || null;
|
||||
return { title, start:s, end:e, allDay, color, recurrence };
|
||||
const location = ev.location || ev.place || '';
|
||||
const description = ev.description || ev.details || ev.note || '';
|
||||
const sourceLabel = ev.__source?.label || (ev.department?.name ?? ev.department) || 'Компания';
|
||||
return { title, start:s, end:e, allDay, color, recurrence, location, description, sourceLabel };
|
||||
}
|
||||
|
||||
function renderVertical(container, rangeLabel, events, ws, we){
|
||||
@ -526,29 +741,35 @@
|
||||
|
||||
list.forEach(ev=>{
|
||||
const row = document.createElement('div'); row.className = 'week-row';
|
||||
// цветовая точка слева от заголовка
|
||||
const dot = document.createElement('span'); dot.className = 'color-dot';
|
||||
dot.style.backgroundColor = ev.color || DEFAULT_EVENT_COLOR;
|
||||
const badge = document.createElement('div'); badge.className = 'date-badge';
|
||||
const dow = dfDow.format(ev.start), md = dfMD.format(ev.start);
|
||||
badge.innerHTML = `<div class="dow">${dow}</div><div class="md">${md}</div>`;
|
||||
const cont = document.createElement('div'); cont.className = 'content';
|
||||
const title = document.createElement('div'); title.className = 'title'; title.textContent = ev.title || 'Без названия'; title.title = ev.title || '';
|
||||
const title = document.createElement('div'); title.className = 'title';
|
||||
title.textContent = ev.title || 'Без названия'; title.title = ev.title || '';
|
||||
// строка с источником, локацией и кратким описанием
|
||||
const meta = document.createElement('div'); meta.className = 'meta';
|
||||
const span = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${md}`;
|
||||
meta.textContent = span;
|
||||
cont.append(title, meta);
|
||||
const dateSpan = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${md}`;
|
||||
const parts = [ev.sourceLabel || '—'];
|
||||
if (ev.location) parts.push(ev.location);
|
||||
if (ev.description) parts.push(truncate(ev.description, 20));
|
||||
meta.textContent = `${dateSpan} • ${parts.join(' • ')}`;
|
||||
const head = document.createElement('div');
|
||||
head.style.display = 'flex'; head.style.alignItems = 'center'; head.style.gap = '8px';
|
||||
head.append(dot, title);
|
||||
cont.append(head, meta);
|
||||
row.append(badge, cont);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateWeekLists(sourceCal){
|
||||
async function updateWeekLists(){
|
||||
const now = new Date(); const ws = startOfWeek(now), we = endOfWeek(now);
|
||||
let items = sourceCal ? (sourceCal.getEvents?.() || []).map(e => ({
|
||||
title: e.title, start: e.start, end: e.end, allDay: e.allDay,
|
||||
color: e.extendedProps?.color, recurrence: e.extendedProps?.recurrence
|
||||
})) : [];
|
||||
if (!items.length){
|
||||
items = await fetchEventsCombined(new Date(ws.getTime()-3*dayMs), new Date(we.getTime()+3*dayMs));
|
||||
}
|
||||
const from = new Date(ws.getTime()-3*dayMs), to = new Date(we.getTime()+3*dayMs);
|
||||
const items = await fetchEventsAllCalendars(from, to);
|
||||
renderVertical(document.getElementById('weekList'), document.getElementById('weekRange'), items, ws, we);
|
||||
renderVertical(document.getElementById('weekListMobile'), document.getElementById('weekRangeMobile'), items, ws, we);
|
||||
}
|
||||
@ -571,8 +792,18 @@
|
||||
displayEventTime: false,
|
||||
events: async (info, success, failure) => {
|
||||
try{
|
||||
const data = await fetchEventsCombined(info.start, info.end);
|
||||
success(data);
|
||||
const raw = await fetchEventsCombined(info.start, info.end);
|
||||
// Нормализуем id и цвет для FullCalendar
|
||||
const mapped = (Array.isArray(raw) ? raw : []).map(ev => {
|
||||
const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id;
|
||||
const color = ev.color || ev.bgColor || null;
|
||||
return {
|
||||
...ev,
|
||||
...(id != null ? { id } : {}),
|
||||
...(color ? { backgroundColor: color, borderColor: color } : {}),
|
||||
};
|
||||
});
|
||||
success(mapped);
|
||||
}catch(e){
|
||||
console.error('Calendar fetch error:', e);
|
||||
failure(e);
|
||||
@ -584,9 +815,10 @@
|
||||
return { domNodes: [el] };
|
||||
},
|
||||
eventDidMount: info => {
|
||||
if (info.event.extendedProps?.color){
|
||||
info.el.style.backgroundColor = info.event.extendedProps.color;
|
||||
info.el.style.borderColor = info.event.extendedProps.color;
|
||||
const col = info.event.backgroundColor || info.event.extendedProps?.color;
|
||||
if (col){
|
||||
info.el.style.backgroundColor = col;
|
||||
info.el.style.borderColor = col;
|
||||
info.el.style.color = 'var(--bs-body-bg)';
|
||||
}
|
||||
if (info.event.extendedProps?.recurrence === 'annual'){
|
||||
@ -605,15 +837,33 @@
|
||||
|
||||
let deskCalendar=null, mobCalendar=null;
|
||||
const desk = document.getElementById('calendarRight');
|
||||
if (desk){ deskCalendar = new FullCalendar.Calendar(desk, fcOpts); deskCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(deskCalendar); }); deskCalendar.render(); }
|
||||
if (desk){
|
||||
deskCalendar = new FullCalendar.Calendar(desk, fcOpts);
|
||||
deskCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(); });
|
||||
deskCalendar.on('eventClick', (info)=>{
|
||||
info.jsEvent?.preventDefault?.();
|
||||
const eid = info.event.id || info.event.extendedProps?.id;
|
||||
if (eid) openEventDetailsById(eid);
|
||||
});
|
||||
deskCalendar.render();
|
||||
}
|
||||
const mob = document.getElementById('calendarRightMobile');
|
||||
if (mob){ mobCalendar = new FullCalendar.Calendar(mob, fcOpts); mobCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(mobCalendar); }); mobCalendar.render(); }
|
||||
if (mob){
|
||||
mobCalendar = new FullCalendar.Calendar(mob, fcOpts);
|
||||
mobCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(); });
|
||||
mobCalendar.on('eventClick', (info)=>{
|
||||
info.jsEvent?.preventDefault?.();
|
||||
const eid = info.event.id || info.event.extendedProps?.id;
|
||||
if (eid) openEventDetailsById(eid);
|
||||
});
|
||||
mobCalendar.render();
|
||||
}
|
||||
|
||||
// загрузим отделы и обновим подпись селектора
|
||||
loadDepartments().then(()=> setChooserLabel());
|
||||
|
||||
setTimeout(()=> updateWeekLists(deskCalendar || mobCalendar), 100);
|
||||
[deskCalendar, mobCalendar].forEach(cal => cal?.on('datesSet', ()=> updateWeekLists(cal)));
|
||||
setTimeout(()=> updateWeekLists(), 100);
|
||||
[deskCalendar, mobCalendar].forEach(cal => cal?.on('datesSet', ()=> updateWeekLists()));
|
||||
|
||||
/* ===== UX: переключалки формы ===== */
|
||||
function syncByRecurrence(){
|
||||
@ -649,8 +899,118 @@
|
||||
allDayChk?.addEventListener('change', syncByAllDay);
|
||||
recurrenceUntil?.addEventListener('change', syncUntilCount);
|
||||
syncByRecurrence(); syncByAllDay(); syncUntilCount();
|
||||
/* ===== Кнопки модала: редактирование/удаление (навешиваем ОДИН раз) ===== */
|
||||
$dt?.btnEdit?.addEventListener?.('click', ()=>{
|
||||
if (!currentDetail) return;
|
||||
const form = document.getElementById('eventForm');
|
||||
if (!form) { detailsModal?.hide(); return; }
|
||||
form.dataset.mode = 'edit';
|
||||
form.dataset.eventId = currentDetail.id;
|
||||
|
||||
/* ===== создание события (POST только с Bearer) ===== */
|
||||
// Базовые поля
|
||||
form.querySelector('[name="title"]').value = currentDetail.title || '';
|
||||
form.querySelector('[name="location"]').value = currentDetail.location || '';
|
||||
form.querySelector('[name="color"]').value = currentDetail.color || DEFAULT_EVENT_COLOR;
|
||||
form.querySelector('[name="all_day"]').checked = !!currentDetail.all_day;
|
||||
|
||||
// НОВОЕ: описание
|
||||
const descEl = form.querySelector('[name="description"]');
|
||||
if (descEl) descEl.value = currentDetail.description || '';
|
||||
|
||||
// Дата/время
|
||||
const startIso = currentDetail.start
|
||||
|| (currentDetail.start_date ? `${currentDetail.start_date}T${(currentDetail.start_time || '00:00').slice(0,5)}` : '');
|
||||
const endIso = currentDetail.end
|
||||
|| (currentDetail.end_date ? `${currentDetail.end_date}T${(currentDetail.end_time || '00:00').slice(0,5)}` : '');
|
||||
const startEl = form.querySelector('[name="start"]');
|
||||
const endEl = form.querySelector('[name="end"]');
|
||||
if (startEl) startEl.value = (startIso || '').slice(0,16);
|
||||
if (endEl) endEl.value = (endIso || '').slice(0,16);
|
||||
|
||||
// Повторы
|
||||
const recSel = form.querySelector('[name="recurrence"]');
|
||||
if (recSel) recSel.value = currentDetail.recurrence || 'one_time';
|
||||
const recInt = form.querySelector('[name="recurrence_interval"]');
|
||||
if (recInt) recInt.value = currentDetail.recurrence_interval || 1;
|
||||
|
||||
// НОВОЕ: until / count
|
||||
const untilEl = form.querySelector('[name="recurrence_until"]');
|
||||
if (untilEl) untilEl.value = (currentDetail.recurrence_until || '').slice(0,10);
|
||||
const countEl = form.querySelector('[name="recurrence_count"]');
|
||||
if (countEl) countEl.value = currentDetail.recurrence_count ?? '';
|
||||
|
||||
// НОВОЕ: weekly дни из маски
|
||||
if (currentDetail.weekdays_mask != null) setWeekdaysFromMask(currentDetail.weekdays_mask);
|
||||
|
||||
// Синхронизация UI
|
||||
try { typeof syncByRecurrence === 'function' && syncByRecurrence(); } catch(_){}
|
||||
|
||||
// Отдел (если есть select)
|
||||
const deptSel = form.querySelector('[name="department_id"]');
|
||||
if (deptSel && currentDetail.department){
|
||||
const val = currentDetail.department.id ?? currentDetail.department;
|
||||
if (val != null) deptSel.value = String(val);
|
||||
}
|
||||
|
||||
// Открываем форму редактирования
|
||||
detailsModal?.hide();
|
||||
setTimeout(()=>{
|
||||
const createModalEl = document.getElementById('eventCreateModal');
|
||||
if (createModalEl) {
|
||||
const createModal = bootstrap.Modal.getOrCreateInstance(createModalEl);
|
||||
createModal.show();
|
||||
} else {
|
||||
const offcanvasEl = document.getElementById('rightbarOffcanvas');
|
||||
if (offcanvasEl) bootstrap.Offcanvas.getOrCreateInstance(offcanvasEl).show();
|
||||
}
|
||||
form.querySelector('[name="title"]')?.focus();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
|
||||
$dt?.btnDel?.addEventListener?.('click', async ()=>{
|
||||
if (!currentDetail) return;
|
||||
if (!confirm('Удалить это событие без возможности восстановления?')) return;
|
||||
try{
|
||||
await apiDelete(`${API_EVENTS}${currentDetail.id}/`);
|
||||
detailsModal?.hide();
|
||||
try{ deskCalendar?.refetchEvents?.(); mobCalendar?.refetchEvents?.(); }catch(_){}
|
||||
updateWeekLists();
|
||||
}catch(err){
|
||||
console.error('Delete event error', err);
|
||||
alert(err.status === 403 ? 'Недостаточно прав для удаления.' : 'Не удалось удалить событие.');
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== Цвет: палитра и синхронизация с input[type=color] ===== */
|
||||
function initColorPicker(){
|
||||
const form = document.getElementById('eventForm');
|
||||
if (!form) return;
|
||||
const colorInput = form.querySelector('input[name="color"]');
|
||||
const palette = document.getElementById('colorPalette');
|
||||
if (!colorInput || !palette) return;
|
||||
|
||||
// Значение по умолчанию
|
||||
if (!colorInput.value) colorInput.value = DEFAULT_EVENT_COLOR;
|
||||
|
||||
function highlightActive(){
|
||||
const val = (colorInput.value || '').toLowerCase();
|
||||
palette.querySelectorAll('[data-color]').forEach(btn=>{
|
||||
btn.classList.toggle('active', (btn.dataset.color || '').toLowerCase() === val);
|
||||
});
|
||||
}
|
||||
palette.querySelectorAll('[data-color]').forEach(btn=>{
|
||||
btn.addEventListener('click', ()=>{
|
||||
colorInput.value = btn.dataset.color || DEFAULT_EVENT_COLOR;
|
||||
colorInput.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
highlightActive();
|
||||
});
|
||||
});
|
||||
colorInput.addEventListener('input', highlightActive);
|
||||
highlightActive();
|
||||
}
|
||||
|
||||
/* ===== создание/редактирование события (POST/PATCH только с Bearer) ===== */
|
||||
const form = document.getElementById('eventForm');
|
||||
form?.addEventListener('submit', async (e)=>{
|
||||
e.preventDefault();
|
||||
@ -681,6 +1041,7 @@
|
||||
// weekly weekdays → массив чисел
|
||||
const weekdays = Array.from(document.querySelectorAll('input[name="weekdays"]:checked')).map(el => parseInt(el.value, 10));
|
||||
|
||||
const colorVal = ((fd.get('color') || '').toString().trim()) || DEFAULT_EVENT_COLOR;
|
||||
// Базовый payload
|
||||
const payload = {
|
||||
title,
|
||||
@ -689,8 +1050,9 @@
|
||||
all_day: allDay,
|
||||
recurrence,
|
||||
recurrence_interval,
|
||||
color: (fd.get('color') || '').toString().trim() || null,
|
||||
color: colorVal,
|
||||
location: (fd.get('location') || '').toString().trim() || '',
|
||||
description: (fd.get('description') || '').toString().trim() || '',
|
||||
};
|
||||
|
||||
// Время (если all_day=false ИЛИ пользователь указал оба времени)
|
||||
@ -724,22 +1086,44 @@
|
||||
payload.department_id = Number(state.deptId);
|
||||
}
|
||||
|
||||
// Debug headers перед отправкой
|
||||
const postHeaders = { 'Content-Type': 'application/json', ...authHeaders() };
|
||||
// console.debug('Headers для POST/PATCH:', postHeaders);
|
||||
// Проверка токена
|
||||
if (!globalToken) {
|
||||
alert('Требуется авторизация. Войдите заново.');
|
||||
return;
|
||||
}
|
||||
|
||||
try{
|
||||
await fetchJSON(API_EVENTS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const isEdit = form.dataset.mode === 'edit' && form.dataset.eventId;
|
||||
const url = isEdit ? (API_EVENTS + String(form.dataset.eventId) + '/') : API_EVENTS;
|
||||
const method = isEdit ? 'PATCH' : 'POST';
|
||||
await fetchJSON(url, {
|
||||
method,
|
||||
headers: postHeaders,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
document.querySelector('#eventCreateModal .btn-close')?.click();
|
||||
form.reset();
|
||||
delete form.dataset.mode;
|
||||
delete form.dataset.eventId;
|
||||
syncByRecurrence(); syncByAllDay(); syncUntilCount();
|
||||
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
|
||||
updateWeekLists();
|
||||
}catch(err){
|
||||
console.error('Create event error', err);
|
||||
const detail = err?.data?.detail || (typeof err?.data === 'string' ? err.data : '');
|
||||
alert('Не удалось создать событие: ' + (detail || JSON.stringify(err?.data || {})));
|
||||
if (err && err.status === 403){
|
||||
const where = (state?.type === 'dept') ? `отделе «${currentDeptLabel()}»` : 'компании';
|
||||
alert(
|
||||
'Недостаточно прав для создания события в ' + where + '.\n' +
|
||||
'Попросите руководителя назначить вам роль «Управлять календарём отдела».'
|
||||
);
|
||||
} else {
|
||||
const detail = err?.data?.detail || (typeof err?.data === 'string' ? err.data : '');
|
||||
alert('Не удалось создать событие: ' + (detail || JSON.stringify(err?.data || {})));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -165,10 +165,7 @@
|
||||
<span class="side-icon"><i class="bi-file-earmark-text"></i></span>
|
||||
<span>Документы</span>
|
||||
</a>
|
||||
<a href="#" class="nav-link disabled" tabindex="-1" aria-disabled="true">
|
||||
<span class="side-icon"><i class="bi-calendar-event"></i></span>
|
||||
<span>Календарь</span>
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto pt-3 small">
|
||||
|
||||
@ -15,10 +15,12 @@ User = get_user_model()
|
||||
|
||||
# ======== фикстуры ========
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _locmem_email_backend(settings):
|
||||
# Все письма складываются в mail.outbox
|
||||
@ -27,6 +29,7 @@ def _locmem_email_backend(settings):
|
||||
|
||||
# ======== утилиты ========
|
||||
|
||||
|
||||
def extract_code_from_last_email() -> str | None:
|
||||
"""
|
||||
В менеджере пользователей письмо выглядит как:
|
||||
@ -71,7 +74,13 @@ def resend(api: APIClient, *, email: str):
|
||||
return api.post(url, {"email": email}, format="json")
|
||||
|
||||
|
||||
def jwt_obtain(api: APIClient, *, email: str | None = None, phone: str | None = None, password: str = "Str0ngPass!"):
|
||||
def jwt_obtain(
|
||||
api: APIClient,
|
||||
*,
|
||||
email: str | None = None,
|
||||
phone: str | None = None,
|
||||
password: str = "Str0ngPass!"
|
||||
):
|
||||
"""
|
||||
Ожидаем, что аутентификация поддерживает вход по email ИЛИ по телефону.
|
||||
- По email: передаём {"email": ..., "password": ...}
|
||||
@ -87,6 +96,7 @@ def jwt_obtain(api: APIClient, *, email: str | None = None, phone: str | None =
|
||||
|
||||
# ======== регистрация: happy-path ========
|
||||
|
||||
|
||||
def test_register_success_sends_email_and_user_inactive(api):
|
||||
resp = register(api)
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
@ -105,7 +115,11 @@ def test_register_success_sends_email_and_user_inactive(api):
|
||||
|
||||
# ======== регистрация: обязательные поля ========
|
||||
|
||||
@pytest.mark.parametrize("missing_field", ["first_name", "last_name", "phone_number", "email", "password", "birth_date"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing_field",
|
||||
["first_name", "last_name", "phone_number", "email", "password", "birth_date"],
|
||||
)
|
||||
def test_register_missing_required_field(api, missing_field):
|
||||
payload = register_payload()
|
||||
payload.pop(missing_field)
|
||||
@ -117,7 +131,11 @@ def test_register_requires_at_least_one_contact(api):
|
||||
payload = register_payload(telegram="", whatsapp="", wechat="")
|
||||
resp = api.post(reverse("api:v1:register"), payload, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "WhatsApp" in resp.json().get("detail", "") or "WeChat" in resp.json().get("detail", "") or "Telegram" in resp.json().get("detail", "")
|
||||
assert (
|
||||
"WhatsApp" in resp.json().get("detail", "")
|
||||
or "WeChat" in resp.json().get("detail", "")
|
||||
or "Telegram" in resp.json().get("detail", "")
|
||||
)
|
||||
|
||||
|
||||
def test_register_birth_date_must_be_valid_date(api):
|
||||
@ -167,6 +185,7 @@ def test_register_accepts_optional_fields(api):
|
||||
|
||||
# ======== подтверждение email ========
|
||||
|
||||
|
||||
def test_verify_email_success_activates_user(api):
|
||||
assert register(api).status_code == status.HTTP_201_CREATED
|
||||
code = extract_code_from_last_email()
|
||||
@ -201,9 +220,15 @@ def test_resend_email_sends_new_code_and_old_becomes_invalid(api):
|
||||
assert new != old
|
||||
|
||||
# старый — не подходит
|
||||
assert verify(api, email="ivan@example.com", code=old).status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert (
|
||||
verify(api, email="ivan@example.com", code=old).status_code
|
||||
== status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# новый — активирует
|
||||
assert verify(api, email="ivan@example.com", code=new).status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
|
||||
assert verify(api, email="ivan@example.com", code=new).status_code in (
|
||||
status.HTTP_200_OK,
|
||||
status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
|
||||
u = User.objects.get(email="ivan@example.com")
|
||||
assert u.is_active and u.email_verified
|
||||
@ -228,6 +253,7 @@ def test_verify_expired_more_than_5_minutes_deletes_account(api, settings):
|
||||
|
||||
# ======== авторизация (JWT): по email и по телефону ========
|
||||
|
||||
|
||||
def test_login_by_email_denied_before_verify(api):
|
||||
assert register(api).status_code == status.HTTP_201_CREATED
|
||||
# до верификации — 401
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
# tests/conftest.py
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from datetime import date, time
|
||||
from typing import Callable, Generator, Optional, Tuple, Type
|
||||
|
||||
import pytest
|
||||
from django.apps import apps as django_apps
|
||||
import itertools
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import Model as DjangoModel
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import (Department, DepartmentPermission, DepartmentRole,
|
||||
Employee, EmployeeDepartment)
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
API_EVENTS = "/api/v1/calendar/events/"
|
||||
|
||||
|
||||
_phone_counter = itertools.count(100_000_000) # 9 цифр
|
||||
|
||||
|
||||
@ -87,6 +92,7 @@ def CalendarEvent() -> Type[models.Model]:
|
||||
def Recurrence():
|
||||
"""Enum повторяемости из приложения (это не модель)."""
|
||||
from calendar_app.models import Recurrence as _Recurrence
|
||||
|
||||
return _Recurrence
|
||||
|
||||
|
||||
@ -187,20 +193,57 @@ def dept_manager_user(make_user) -> models.Model:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def give_manage_calendar_perm() -> Callable[[models.Model], None]:
|
||||
"""Выдаёт пользователю кастомное право управления календарём отдела.
|
||||
def give_manage_calendar_perm(db) -> Callable[[DjangoModel, DjangoModel], None]:
|
||||
"""Возвращает функцию, которая назначает пользователю ролевое право управления календарём в указанном отделе.
|
||||
|
||||
Args:
|
||||
user (models.Model): Пользователь.
|
||||
db: Стандартная фикстура pytest-django для доступа к БД.
|
||||
|
||||
Returns:
|
||||
Callable[[DjangoModel, DjangoModel], None]: Функция-замыкание, принимающая (user, department).
|
||||
|
||||
Raises:
|
||||
Permission.DoesNotExist: Если пермишен не найден (исправьте codename, если у вас другой).
|
||||
TypeError: Если переданы объекты не являются Django-моделями.
|
||||
ValueError: Если user или department не сохранены в БД (нет pk).
|
||||
IntegrityError: При ошибке записи в БД.
|
||||
"""
|
||||
|
||||
def _grant(user: models.Model) -> None:
|
||||
perm = Permission.objects.get(codename="manage_department_events")
|
||||
user.user_permissions.add(perm)
|
||||
user.save()
|
||||
def _grant(user: DjangoModel, department: DjangoModel) -> None:
|
||||
"""Назначает пользователю роль с правом DeptPerm.MANAGE_CALENDAR в указанном отделе.
|
||||
|
||||
Args:
|
||||
user (DjangoModel): Сотрудник, которому выдаётся право (ваша модель пользователя/Employee).
|
||||
department (DjangoModel): Отдел, в котором выдаётся право.
|
||||
|
||||
Raises:
|
||||
TypeError: Если объекты не наследуются от django.db.models.Model.
|
||||
ValueError: Если объекты не имеют первичных ключей.
|
||||
IntegrityError: Если нарушены ограничения БД при сохранении.
|
||||
"""
|
||||
if not isinstance(user, DjangoModel) or not isinstance(department, DjangoModel):
|
||||
raise TypeError("Ожидаются экземпляры моделей Django (user и department).")
|
||||
if not getattr(user, "pk", None) or not getattr(department, "pk", None):
|
||||
raise ValueError("user и department должны быть предварительно сохранены (иметь pk).")
|
||||
|
||||
# 1) Скоуп-пермишен отдела
|
||||
perm, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=DeptPerm.MANAGE_CALENDAR,
|
||||
defaults={"name": "Управлять календарём отдела"},
|
||||
)
|
||||
|
||||
# 2) Роль внутри отдела
|
||||
role, _ = DepartmentRole.objects.get_or_create(
|
||||
department=department,
|
||||
name="Manager",
|
||||
)
|
||||
role.scoped_permissions.add(perm)
|
||||
|
||||
# 3) Привязка сотрудника к отделу с этой ролью
|
||||
EmployeeDepartment.objects.update_or_create(
|
||||
employee=user,
|
||||
department=department,
|
||||
defaults={"role": role, "is_active": True},
|
||||
)
|
||||
|
||||
return _grant
|
||||
|
||||
|
||||
@ -71,10 +71,10 @@ class TestDepartmentPermissions:
|
||||
assert r1.status_code in (403, 401)
|
||||
|
||||
# с правом
|
||||
give_manage_calendar_perm(dept_manager_user)
|
||||
give_manage_calendar_perm(dept_manager_user, dept)
|
||||
r2 = client.post(api_url, payload, format="json")
|
||||
assert r2.status_code == 201
|
||||
assert r2.data.get("department") == dept.pk or r2.data.get("department_id") == dept.pk
|
||||
# assert r2.data.get("department") == dept.pk or r2.data.get("department_id") == dept.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user