diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index db2dd96e..507b24ac 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -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") \ No newline at end of file + fields = ("id", "name") diff --git a/backend/api/v1/employees/utils.py b/backend/api/v1/employees/utils.py new file mode 100644 index 00000000..813d388e --- /dev/null +++ b/backend/api/v1/employees/utils.py @@ -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": , "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 diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 7228e33b..104ff046 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -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": , "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 diff --git a/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py new file mode 100644 index 00000000..c7d0f099 --- /dev/null +++ b/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py @@ -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', + ), + ] diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py index b7db0c58..189b58b8 100644 --- a/backend/calendar_app/models.py +++ b/backend/calendar_app/models.py @@ -131,9 +131,6 @@ class CalendarEvent(models.Model): condition=~models.Q(source=""), ), ] - permissions = [ - ("manage_department_events", "Может управлять событиями отдела"), - ] def __str__(self) -> str: scope = self.department or _("Компания") diff --git a/backend/employees/forms_auth.py b/backend/employees/forms_auth.py index 5fc06347..02cb5d59 100644 --- a/backend/employees/forms_auth.py +++ b/backend/employees/forms_auth.py @@ -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:;base64,' или 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): diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py index a34c0957..62be4176 100644 --- a/backend/employees/urls_front.py +++ b/backend/employees/urls_front.py @@ -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("/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//roles/edit/", views_front.edit_department_role, diff --git a/backend/employees/views_auth.py b/backend/employees/views_auth.py index ab65806a..5052514d 100644 --- a/backend/employees/views_auth.py +++ b/backend/employees/views_auth.py @@ -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/" diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 66887d77..fcfe6c38 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -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"]) diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py index 5a49db7e..504973ea 100644 --- a/backend/eusrr_backend/middleware.py +++ b/backend/eusrr_backend/middleware.py @@ -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" ) diff --git a/backend/templates/auth/login.html b/backend/templates/auth/login.html index d3b77ddb..e2d0b565 100644 --- a/backend/templates/auth/login.html +++ b/backend/templates/auth/login.html @@ -3,6 +3,21 @@ {% block title %}Авторизация{% endblock %} +{# ВАЖНО: применяем тему ещё в , чтобы не было «мигания» #} +{% block extra_css %} + +{% endblock %} + {% block content %}
@@ -14,7 +29,6 @@
{% csrf_token %} - {% if form.non_field_errors %}
{% for error in form.non_field_errors %} @@ -24,16 +38,13 @@
{% endif %} -
+ required autofocus> {% if form.username.errors %}
@@ -42,7 +53,6 @@ {% endif %}
-
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(); }); diff --git a/backend/templates/auth/password_change_done.html b/backend/templates/auth/password_change_done.html deleted file mode 100644 index 68c7885d..00000000 --- a/backend/templates/auth/password_change_done.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}Пароль изменен{% endblock %} - -{% block content %} -
-
-
-
-

Успешная смена пароля

-
-
- -

Ваш пароль был успешно изменен!

- - Войти с новым паролем - -
-
-
-
-{% endblock %} diff --git a/backend/templates/auth/password_reset.html b/backend/templates/auth/password_reset.html index 6ef30a79..aa915986 100644 --- a/backend/templates/auth/password_reset.html +++ b/backend/templates/auth/password_reset.html @@ -3,28 +3,80 @@ {% block title %}Сброс пароля{% endblock %} +{# Раннее применение темы, синхронно с переключателем в base.html #} +{% block extra_css %} + +{% endblock %} + {% block content %}
-
-
-
-

Сброс пароля

-
-
- - {% csrf_token %} -
-
- {{ form.email }} - -
-
- - -
+
+
+
+

Сброс пароля

+
+ +
+ {# Общие ошибки формы #} + {% if form.non_field_errors %} + + {% endif %} + +
+ {% csrf_token %} + +
+ + + {% if form.email.errors %} +
+ {{ form.email.errors|striptags }} +
+ {% else %} +
Мы отправим инструкции по сбросу на этот адрес.
+ {% endif %} +
+ + +
+
+
{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/auth/password_reset_complete.html b/backend/templates/auth/password_reset_complete.html index bf48f2ef..71e979b0 100644 --- a/backend/templates/auth/password_reset_complete.html +++ b/backend/templates/auth/password_reset_complete.html @@ -3,21 +3,36 @@ {% block title %}Пароль восстановлен{% endblock %} +{# Раннее применение темы — синхронно с переключателем из base.html #} +{% block extra_css %} + +{% endblock %} + {% block content %}
-
-
-
-

Пароль успешно изменен

-
-
- -

Ваш новый пароль был установлен. Теперь вы можете войти в систему.

- - Войти в систему - -
-
-
+
+
+
+

Пароль успешно изменён

+
+
+ +

Ваш новый пароль установлен. Теперь вы можете войти в систему.

+ + Войти в систему + +
+
+
{% endblock %} diff --git a/backend/templates/auth/password_reset_confirm.html b/backend/templates/auth/password_reset_confirm.html index 449afdb0..b28b5c12 100644 --- a/backend/templates/auth/password_reset_confirm.html +++ b/backend/templates/auth/password_reset_confirm.html @@ -3,34 +3,112 @@ {% block title %}Новый пароль{% endblock %} +{# Раннее применение темы — синхронно с переключателем из base.html #} +{% block extra_css %} + +{% endblock %} + {% block content %}
-
-
-
-

Установка нового пароля

-
-
-
- {% csrf_token %} -
-
- {{ form.new_password1 }} - -
-
-
-
- {{ form.new_password2 }} - -
-
- -
-
+
+
+
+

Установка нового пароля

+
+ +
+ {# Общие ошибки формы #} + {% if form.non_field_errors %} + + {% endif %} + +
+ {% csrf_token %} + +
+ + + + {% if form.new_password1.errors %} +
+ {{ form.new_password1.errors|striptags }} +
+ {% elif form.new_password1.help_text %} +
{{ form.new_password1.help_text|safe }}
+ {% endif %} +
+ +
+ + + + {% if form.new_password2.errors %} +
+ {{ form.new_password2.errors|striptags }} +
+ {% endif %} +
+ + +
+
+
{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/auth/password_reset_done.html b/backend/templates/auth/password_reset_done.html index 0008a8e8..5c80636c 100644 --- a/backend/templates/auth/password_reset_done.html +++ b/backend/templates/auth/password_reset_done.html @@ -3,24 +3,39 @@ {% block title %}Письмо отправлено{% endblock %} +{# Раннее применение темы — синхронно с переключателем из base.html #} +{% block extra_css %} + +{% endblock %} + {% block content %}
-
-
-
-

Инструкции отправлены

-
-
- -

- На ваш email отправлены инструкции по сбросу пароля. - Проверьте почту и следуйте указаниям в письме. -

- - Вернуться к авторизации - -
-
-
+
+
+
+

Инструкции отправлены

+
+
+ +

+ На ваш email отправлены инструкции по сбросу пароля. + Проверьте почту и следуйте указаниям в письме. +

+ + Вернуться к авторизации + +
+
+
{% endblock %} diff --git a/backend/templates/auth/password_reset_email.txt b/backend/templates/auth/password_reset_email.txt index 2375a38e..0e333228 100644 --- a/backend/templates/auth/password_reset_email.txt +++ b/backend/templates/auth/password_reset_email.txt @@ -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 %} Если вы не запрашивали сброс пароля, просто игнорируйте это письмо. diff --git a/backend/templates/auth/register.html b/backend/templates/auth/register.html index c0eca342..b30ab9fb 100644 --- a/backend/templates/auth/register.html +++ b/backend/templates/auth/register.html @@ -1,127 +1,281 @@ + {% extends 'base.html' %} {% load static django_bootstrap5 %} {% block title %}Регистрация сотрудника{% endblock %} +{# ВАЖНО: применяем тему ещё в , чтобы не было «мигания» темы #} +{% block extra_css %} + +{% endblock %} + {% block content %}
-
-
-
-

Регистрация нового сотрудника

+
+
+ {# Заголовок без жёсткой покраски — следуем теме #} +
+

Регистрация нового сотрудника

+
+ +
+
+ {% csrf_token %} + + +
+
+ {% bootstrap_field form.email show_label=False placeholder="E-mail" %} + Пример: ivanov@company.com
- -
- - {% csrf_token %} - - -
-
- {% bootstrap_field form.email show_label=False placeholder="E-mail" %} - Пример: ivanov@company.com -
-
- {% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %} -
-
- - -
-
- {% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %} - - - -
-
- {% bootstrap_field form.telegram show_label=False placeholder="Telegram" %} - - - -
-
- {% bootstrap_field form.wechat show_label=False placeholder="WeChat" %} - - - -
- {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} - -
- {% endif %} -
- - -
- {% for field in form %} - {% if field.name == 'last_name' or field.name == 'first_name' or field.name == 'patronymic' %} -
- {% bootstrap_field field show_label=False %} -
- {% endif %} - {% endfor %} -
- {% if form.birth_date %} -
-
- {% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %} -
-
- {% endif %} - - -
-
- {% bootstrap_field form.password1 show_label=False placeholder="Пароль" %} - - - -
-
- {% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %} - - - -
-
- - -
- {% bootstrap_field form.avatar %} - Рекомендуемый размер: 200x200 px -
- -
- - Уже есть аккаунт? Войти -
- +
+ {% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
-
+
+ + +
+
+ {% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %} + + + +
+
+ {% bootstrap_field form.telegram show_label=False placeholder="Telegram" %} + + + +
+
+ {% bootstrap_field form.wechat show_label=False placeholder="WeChat" %} + + + +
+ + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} + +
+ {% endif %} +
+ + +
+
{% bootstrap_field form.last_name show_label=False placeholder="Фамилия" %}
+
{% bootstrap_field form.first_name show_label=False placeholder="Имя" %}
+
{% bootstrap_field form.patronymic show_label=False placeholder="Отчество" %}
+
+ + +
+
+ {% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ {% bootstrap_field form.password1 show_label=False placeholder="Пароль" %} + + + +
+
+ {% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %} + + + +
+
+ + +
+
+
+ Превью аватара + +
+
+
+ {% bootstrap_field form.avatar %} + Рекомендуемый размер: 200×200 px +
+
+ +
+ + Уже есть аккаунт? Войти +
+ +
+
{% endblock %} {% block extra_js %} + {% endblock %} + diff --git a/backend/templates/auth/verify_email.html b/backend/templates/auth/verify_email.html index a38f9396..6af7543b 100644 --- a/backend/templates/auth/verify_email.html +++ b/backend/templates/auth/verify_email.html @@ -2,6 +2,21 @@ {% block title %}Подтверждение email — EUSRR{% endblock %} +{# ВАЖНО: применяем тему ещё в , до рендера страницы #} +{% block extra_css %} + +{% endblock %} + {% block content %}
{% if messages %} @@ -67,8 +82,7 @@ {% block extra_js %} {% endblock %} diff --git a/backend/templates/calendar/calendar.html b/backend/templates/calendar/calendar.html deleted file mode 100644 index b4d66479..00000000 --- a/backend/templates/calendar/calendar.html +++ /dev/null @@ -1,23 +0,0 @@ - -{% extends "base.html" %} -{% block content %} -
- - - -{% endblock %} diff --git a/backend/templates/emails/password_reset_email.html b/backend/templates/emails/password_reset_email.html new file mode 100644 index 00000000..a1c23b4e --- /dev/null +++ b/backend/templates/emails/password_reset_email.html @@ -0,0 +1,13 @@ + + + +

Сброс пароля

+

Здравствуйте {{ user.get_full_name|default_if_none:"" }}!

+

Перейдите по ссылке для смены пароля:

+
+ {{ protocol }}://{{ domain }}{% url 'auth_front:password_reset_confirm' uidb64=uid token=token %} +
+
+

Если вы не запрашивали сброс пароля, просто игнорируйте это письмо.

+ + diff --git a/backend/templates/employees/department_form.html b/backend/templates/employees/department_create.html similarity index 100% rename from backend/templates/employees/department_form.html rename to backend/templates/employees/department_create.html diff --git a/backend/templates/employees/department_list.html b/backend/templates/employees/department_list.html index 9e70a3fe..3f3b9c9f 100644 --- a/backend/templates/employees/department_list.html +++ b/backend/templates/employees/department_list.html @@ -5,7 +5,7 @@ {% block extra_css %} {% endblock %} {% block content %} @@ -29,7 +37,7 @@

Отделы

- + Всего: {{ departments|length }}
- + {% if can_create_departments or perms.employees.add_department or request.user.is_staff or request.user.is_superuser %} + + {% endif %}
{# поддержка обоих форматов: 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(); }); })(); + {% endblock %} diff --git a/backend/templates/employees/employees_list.html b/backend/templates/employees/employees_list.html index 259fe1ec..b9cb3af4 100644 --- a/backend/templates/employees/employees_list.html +++ b/backend/templates/employees/employees_list.html @@ -133,7 +133,16 @@
- + {% if perms.employees.add_employee or request.user.is_staff or request.user.is_superuser %} + + {% endif %}
{% if employees %} {% for emp in employees %} diff --git a/backend/templates/includes/rightbar_calendar.html b/backend/templates/includes/rightbar_calendar.html index e004a09b..86586eb4 100644 --- a/backend/templates/includes/rightbar_calendar.html +++ b/backend/templates/includes/rightbar_calendar.html @@ -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; + } {# десктопная правая колонка #} @@ -194,6 +213,10 @@
+
+ + +
@@ -277,7 +300,19 @@
- +
+ +
+ + + + + + + + +
+
@@ -291,17 +326,52 @@
- + +