Календарь (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).
203 lines
6.9 KiB
Python
203 lines
6.9 KiB
Python
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
|