diff --git a/backend/employees/authentication.py b/backend/employees/authentication.py deleted file mode 100644 index bcd9df47..00000000 --- a/backend/employees/authentication.py +++ /dev/null @@ -1,30 +0,0 @@ -# backend\employees\authentication.py -from django.contrib.auth.backends import ModelBackend - -from .models import Employee - - -class EmailOrPhoneBackend(ModelBackend): - def authenticate(self, request, username=None, password=None, **kwargs): - if username is None: - username = kwargs.get("username") - - try: - # Проверяем, email ли это - if "@" in username: - user = Employee.objects.get(email=username) - else: - # Иначе phone - user = Employee.objects.get(phone_number=username) - except Employee.DoesNotExist: - return None - - if user.check_password(password) and self.user_can_authenticate(user): - return user - return None - - def get_user(self, user_id): - try: - return Employee.objects.get(pk=user_id) - except Employee.DoesNotExist: - return None diff --git a/backend/employees/constants.py.orig b/backend/employees/constants.py.orig deleted file mode 100644 index 73f01094..00000000 --- a/backend/employees/constants.py.orig +++ /dev/null @@ -1,53 +0,0 @@ -# backend\employees\constants.py -GENDER_CHOICES = ( - (1, "Мужской"), - (2, "Женский"), - (0, "Не указан"), -) - - -ACTION_HIRED = "hired" -ACTION_DISMISSED = "dismissed" -ACTION_ON_LEAVE = "on_leave" -ACTION_RETURNED_FROM_LEAVE = "returned_from_leave" -ACTION_ON_MATERNITY = "on_maternity" -ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity" -ACTION_TRANSFERRED = "transferred" -ACTION_REHIRED = "rehired" - -ACTION_CHOICES = [ - (ACTION_HIRED, "Принят"), - (ACTION_DISMISSED, "Уволен"), - (ACTION_ON_LEAVE, "В отпуске"), - (ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"), - (ACTION_ON_MATERNITY, "В декрете"), - (ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"), - (ACTION_TRANSFERRED, "Переведен"), - (ACTION_REHIRED, "Восстановлен"), -] - - -class DeptPerm: - MANAGE = "manage_department" - CHANGE_HEAD = "change_department_head" - ASSIGN_ROLE = "assign_department_role" - MANAGE_CALENDAR = "manage_department_events" - CREATE_POST = "publish_department_post" - MANAGE_FEED = "manage_department_feed" - VIEW_REQUESTCOMMENT = "view_requestcomment" - ADD_REQUESTCOMMENT = "add_requestcomment" - - CHOICES = ( - (MANAGE, "Управлять отделом"), - (CHANGE_HEAD, "Назначать руководителя"), - (ASSIGN_ROLE, "Назначать роли участникам"), - (MANAGE_CALENDAR, "Управлять календарём отдела"), - (CREATE_POST, "Публиковать новости на странице отдела"), - (MANAGE_FEED, "Редактировать публикации отдела"), - (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), - (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), -<<<<<<< HEAD - ) -======= - ) ->>>>>>> origin/feat/ldap-writeback diff --git a/backend/employees/forms_auth.py b/backend/employees/forms_auth.py deleted file mode 100644 index 045360e8..00000000 --- a/backend/employees/forms_auth.py +++ /dev/null @@ -1,386 +0,0 @@ -# backend/employees/forms_auth.py -from __future__ import annotations - -import base64 -import logging -import re -from typing import Any, Dict, List, Optional - -from django import forms -from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm -from django.core.files.uploadedfile import UploadedFile -from PIL import Image - -logger = logging.getLogger(__name__) - - -class EmailOrPhoneAuthenticationForm(AuthenticationForm): - username = forms.CharField( - label="Email или телефон", - widget=forms.TextInput( - attrs={ - "autofocus": True, - "placeholder": "user@example.com или +7 999 123-45-67", - } - ), - ) - password = forms.CharField( - label="Пароль", - strip=False, - widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), - ) - - -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=17, # + и до 15 цифр (с запасом) - label="Номер телефона", - widget=forms.TextInput(attrs={ - "type": "tel", - "inputmode": "numeric", - "autocomplete": "tel", - "title": "Введите номер в формате +79991234567", - }) - ) - email = forms.EmailField(label="Email") - birth_date = forms.DateField( - label="Дата рождения", widget=forms.DateInput(attrs={"type": "date"}) - ) - - # Пароли как в шаблоне (password1/password2) - password1 = forms.CharField(label="Пароль", widget=forms.PasswordInput) - password2 = forms.CharField(label="Повторите пароль", widget=forms.PasswordInput) - - # Контакты: нужен хотя бы один - telegram = forms.CharField(required=False, label="Telegram") - whatsapp = forms.CharField(required=False, label="WhatsApp") - wechat = forms.CharField(required=False, label="WeChat") - - # Опциональные поля модели - patronymic = forms.CharField(required=False, label="Отчество") - gender = forms.ChoiceField( - required=True, - label="Пол", - choices=[(1, "Мужской"), (2, "Женский")], - widget=forms.RadioSelect, - error_messages={ - 'required': 'Необходимо указать пол.', - 'invalid_choice': 'Выберите Мужской или Женский.', - } - ) - position = forms.IntegerField(required=False, label="ID должности") - # skills — список ID через запятую - skills = forms.CharField(required=False, label="Навыки (ID через запятую)") - avatar = forms.ImageField(required=True, label="Аватар") - - def clean_first_name(self) -> str: - """Валидация имени: только буквы, пробелы и дефисы.""" - value = self.cleaned_data.get("first_name", "").strip() - if not value: - raise forms.ValidationError("Имя обязательно для заполнения.") - if re.search(r'\d', value): - raise forms.ValidationError( - "Имя не должно содержать цифры." - ) - # Разрешаем буквы (любых алфавитов), пробелы, дефисы, апострофы - if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): - raise forms.ValidationError( - "Имя может содержать только буквы, пробелы, дефисы и апострофы." - ) - return value - - def clean_last_name(self) -> str: - """Валидация фамилии: только буквы, пробелы и дефисы.""" - value = self.cleaned_data.get("last_name", "").strip() - if not value: - raise forms.ValidationError("Фамилия обязательна для заполнения.") - if re.search(r'\d', value): - raise forms.ValidationError( - "Фамилия не должна содержать цифры." - ) - if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): - raise forms.ValidationError( - "Фамилия может содержать только буквы, пробелы, дефисы и апострофы." - ) - return value - - def clean_patronymic(self) -> str: - """Валидация отчества: только буквы, пробелы и дефисы.""" - value = self.cleaned_data.get("patronymic", "").strip() - if not value: - return value # Отчество опциональное - if re.search(r'\d', value): - raise forms.ValidationError( - "Отчество не должно содержать цифры." - ) - if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): - raise forms.ValidationError( - "Отчество может содержать только буквы, пробелы, дефисы и апострофы." - ) - return value - - def clean(self) -> Dict[str, Any]: - """Глобальная валидация формы. - - Проверяет: - - наличие хотя бы одного контактного поля (Telegram/WhatsApp/WeChat); - - корректность паролей (оба заполнены и совпадают). - - Returns: - dict: Очищенные данные формы. - """ - data = super().clean() - - # хотя бы один контакт - 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") 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]: - """Возвращает список ID навыков из текстового поля. - - Returns: - list[int]: Список целых ID. Пустой, если поле пустое или состоит из мусора. - """ - raw = (self.cleaned_data.get("skills") or "").strip() - if not raw: - return [] - out: List[int] = [] - for chunk in raw.split(","): - s = chunk.strip() - if not s: - continue - try: - out.append(int(s)) - except ValueError: - # игнорируем мусор - continue - return out - - # ---------- Вспомогательные методы для аватара ---------- - - @staticmethod - def _guess_mime(upload: UploadedFile, content: bytes) -> str: - """Определяет MIME-тип изображения. - - Args: - upload: Загруженный файл (может содержать content_type). - content: Байты файла. - - Returns: - str: MIME-тип, например 'image/jpeg'. - """ - # 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: Если файл нельзя прочитать. - """ - 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"], - "email": cd["email"], - "birth_date": cd["birth_date"].isoformat(), - "password": cd["password1"], - "gender": int(cd["gender"]), # обязательное поле - "telegram": cd.get("telegram") or "", - "whatsapp": cd.get("whatsapp") or "", - "wechat": cd.get("wechat") or "", - } - - # Доп. поля - if cd.get("patronymic"): - payload["patronymic"] = cd["patronymic"] - if cd.get("position") is not None: - payload["position"] = cd["position"] - - ids = self.skills_ids() - if ids: - payload["skills"] = ids - - # Аватар → 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): - email = forms.EmailField(label="Email") - code = forms.CharField(max_length=32, label="Код из письма") - -class CustomPasswordResetForm(PasswordResetForm): - """Кастомная форма восстановления пароля с логирование""" - - def save(self, *args, **kwargs): - """Переопределяем save для логирования процесса отправки""" - email = self.cleaned_data["email"] - - logger.info( - f"\n{'='*80}\n" - f"[CustomPasswordResetForm.save] 🔐 СОХРАНЕНИЕ ФОРМЫ ВОССТАНОВЛЕНИЯ ПАРОЛЯ\n" - f" Email: {email}\n" - f"{'='*80}" - ) - - logger.info( - f"[CustomPasswordResetForm.save] ➡️ Вызов super().save() для отправки emails..." - ) - - try: - result = super().save(*args, **kwargs) - logger.info( - f"[CustomPasswordResetForm.save] ✅ super().save() успешно завершен" - ) - return result - except Exception as e: - logger.error( - f"[CustomPasswordResetForm.save] ❌ Ошибка в super().save(): {type(e).__name__}: {e}", - exc_info=True - ) - raise - - def send_mail(self, subject_template_name, email_template_name, context, - from_email, to_email, html_email_template_name=None, **kwargs): - """Переопределяем send_mail для логирования отправки каждого письма""" - logger.info( - f"\n{'='*80}\n" - f"[CustomPasswordResetForm.send_mail] 📧 ОТПРАВКА EMAIL\n" - f" To: {to_email}\n" - f" Subject template: {subject_template_name}\n" - f" Email template: {email_template_name}\n" - f" HTML template: {html_email_template_name}\n" - f"{'='*80}" - ) - - try: - # Вызываем родительский метод - result = super().send_mail( - subject_template_name=subject_template_name, - email_template_name=email_template_name, - context=context, - from_email=from_email, - to_email=to_email, - html_email_template_name=html_email_template_name, - **kwargs - ) - logger.info( - f"[CustomPasswordResetForm.send_mail] ✅ EMAIL УСПЕШНО ОТПРАВЛЕН на {to_email}" - ) - return result - except Exception as e: - logger.error( - f"[CustomPasswordResetForm.send_mail] ❌ ОШИБКА ОТПРАВКИ EMAIL: {type(e).__name__}: {e}", - exc_info=True - ) - raise \ No newline at end of file diff --git a/backend/employees/forms_front.py b/backend/employees/forms_front.py deleted file mode 100644 index 035a428b..00000000 --- a/backend/employees/forms_front.py +++ /dev/null @@ -1,58 +0,0 @@ -# backend\employees\forms_front.py -from django import forms - -from .models import Department, Employee - - -class DepartmentEditForm(forms.Form): - name = forms.CharField(max_length=255, label="Название") - description = forms.CharField( - required=False, widget=forms.Textarea, label="Описание" - ) - - -class SetHeadForm(forms.Form): - # просто номер id; мы не тянем ORM - head_id = forms.IntegerField( - required=False, label="ID руководителя (пусто — снять)" - ) - - -class SetMemberRoleForm(forms.Form): - employee_id = forms.IntegerField(label="ID сотрудника") - role_id = forms.IntegerField(required=False, label="ID роли (пусто — снять)") - is_active = forms.BooleanField(required=False, label="Активен в отделе") - - -class DepartmentForm(forms.ModelForm): - class Meta: - model = Department - fields = ["name", "description", "head"] - - -class EmployeeForm(forms.ModelForm): - class Meta: - model = Employee - fields = ( - "first_name", - "last_name", - "patronymic", - "telegram", - "whatsapp", - "wechat", - ) - widgets = { - "first_name": forms.TextInput(attrs={"autocomplete": "given-name"}), - "last_name": forms.TextInput(attrs={"autocomplete": "family-name"}), - "whatsapp": forms.TextInput(attrs={"inputmode": "tel"}), - "telegram": forms.TextInput(), - "wechat": forms.TextInput(), - } - labels = { - "first_name": "Имя", - "last_name": "Фамилия", - "patronymic": "Отчество", - "telegram": "Telegram", - "whatsapp": "WhatsApp", - "wechat": "WeChat", - } diff --git a/backend/employees/tests/__init__.py b/backend/employees/tests/__init__.py deleted file mode 100644 index 0d2bf69c..00000000 --- a/backend/employees/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Тесты для employees приложения. -""" diff --git a/backend/employees/tests/test_birthday_services.py b/backend/employees/tests/test_birthday_services.py deleted file mode 100644 index 3afa5e36..00000000 --- a/backend/employees/tests/test_birthday_services.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -Тесты для сервисного слоя работы с днями рождения (Service Layer Pattern). -Используется django-service-objects для инкапсуляции бизнес-логики. -""" -import pytest -from datetime import date -from django.contrib.auth import get_user_model -from django.db.models import signals -from django.utils import timezone - -from employees.models import Employee -from employees.services import ( - UpsertBirthdayEventService, - DeleteBirthdayEventService, - BulkSyncBirthdaysService -) -from schedule.models import Calendar, Event, Rule - -User = get_user_model() - - -@pytest.fixture(autouse=True) -def disable_birthday_signals(settings): - """Отключаем сигналы дней рождений для изолированного тестирования сервисов.""" - from unittest.mock import patch - - # Мокируем только функции-обработчики сигналов, а не сами сервисы - with patch('employees.signals_birthday.sync_birthday_event_on_employee_save'): - with patch('employees.signals_birthday.delete_birthday_event_on_employee_delete'): - yield - - -@pytest.fixture -def create_test_employee(): - """Фикстура для создания тестовых сотрудников с валидными данными.""" - employee_counter = 0 - - def _create_employee(**kwargs): - nonlocal employee_counter - employee_counter += 1 - defaults = { - 'username': f'testuser{employee_counter}', - 'email': f'testuser{employee_counter}@example.com', - 'phone_number': f'+7900000{employee_counter:04d}', - 'password': 'test123', - 'first_name': 'Test', - 'last_name': f'User{employee_counter}' - } - defaults.update(kwargs) - - # Employee наследуется от AbstractUser - employee = Employee.objects.create_user(**defaults) - return employee - - return _create_employee - - -@pytest.mark.django_db -class TestUpsertBirthdayEventService: - """Тесты для сервиса создания/обновления событий дня рождения.""" - - def test_create_birthday_event_for_new_employee(self, create_test_employee): - """Создание нового события дня рождения.""" - # Arrange - employee = create_test_employee( - username='john', - email='john@example.com', - first_name='John', - last_name='Doe', - birth_date=date(1990, 5, 15) - ) - - # Act - result = UpsertBirthdayEventService.execute({'employee': employee}) - - # Assert - assert result['success'] is True - assert result['created'] is True - assert result['event'] is not None - - event = result['event'] - assert event.title == '🎂 День рождения: John Doe' - assert event.creator_id == employee.pk - assert event.rule is not None - assert event.rule.frequency == 'YEARLY' - assert event.end_recurring_period is None # Бесконечное повторение - - def test_update_existing_birthday_event(self, create_test_employee): - """Обновление существующего события при изменении данных.""" - # Arrange - employee = create_test_employee( - username='jane', - email='jane@example.com', - first_name='Jane', - last_name='Smith', - birth_date=date(1985, 3, 20) - ) - - # Создаем событие - first_result = UpsertBirthdayEventService.execute({'employee': employee}) - first_event_id = first_result['event'].id - - # Act - меняем имя сотрудника - employee.first_name = 'Janet' - employee.save() - - second_result = UpsertBirthdayEventService.execute({'employee': employee}) - - # Assert - assert second_result['success'] is True - assert second_result['created'] is False # Обновлено, не создано - assert second_result['event'].id == first_event_id # Тот же event - assert second_result['event'].title == '🎂 День рождения: Janet Smith' - - def test_skip_employee_without_birth_date(self, create_test_employee): - """Пропуск сотрудника без даты рождения.""" - # Arrange - employee = create_test_employee( - username='nobirthday', - email='nobirthday@example.com', - first_name='No', - last_name='Birthday', - birth_date=None - ) - - # Act - result = UpsertBirthdayEventService.execute({'employee': employee}) - - # Assert - assert result['success'] is False - assert result['reason'] == 'no_birth_date' - assert result['event'] is None - - def test_creates_personal_calendar_if_not_exists(self, create_test_employee): - """Автоматически создает личный календарь при первом событии.""" - # Arrange - employee = create_test_employee( - username='newuser', - email='newuser@example.com', - first_name='New', - last_name='User', - birth_date=date(1992, 7, 10) - ) - - # Убедимся, что календаря нет - assert not Calendar.objects.filter(slug=f'employee-{employee.pk}').exists() - - # Act - result = UpsertBirthdayEventService.execute({'employee': employee}) - - # Assert - assert result['success'] is True - calendar = Calendar.objects.get(slug=f'employee-{employee.pk}') - assert calendar.name == '👤 Мой календарь' - assert result['event'].calendar == calendar - - def test_yearly_rule_reused_across_employees(self, create_test_employee): - """Правило 'Ежегодно' переиспользуется для всех событий.""" - # Arrange - employee1 = create_test_employee( - username='user1', - email='user1@example.com', - first_name='User', - last_name='One', - birth_date=date(1990, 1, 1) - ) - employee2 = create_test_employee( - username='user2', - email='user2@example.com', - first_name='User', - last_name='Two', - birth_date=date(1991, 2, 2) - ) - - # Act - result1 = UpsertBirthdayEventService.execute({'employee': employee1}) - result2 = UpsertBirthdayEventService.execute({'employee': employee2}) - - # Assert - assert result1['event'].rule.id == result2['event'].rule.id - assert Rule.objects.filter(name='Ежегодно').count() == 1 - - -@pytest.mark.django_db -class TestDeleteBirthdayEventService: - """Тесты для сервиса удаления событий дня рождения.""" - - def test_delete_existing_birthday_event(self, create_test_employee): - """Удаление существующего события.""" - # Arrange - employee = create_test_employee( - username='todelete', - email='todelete@example.com', - first_name='To', - last_name='Delete', - birth_date=date(1988, 4, 25) - ) - - # Создаем событие - UpsertBirthdayEventService.execute({'employee': employee}) - assert Event.objects.filter(creator_id=employee.pk).exists() - - # Act - result = DeleteBirthdayEventService.execute({'employee': employee}) - - # Assert - assert result['success'] is True - assert result['deleted_count'] == 1 - assert not Event.objects.filter(creator_id=employee.pk).exists() - - def test_delete_nonexistent_event_returns_false(self, create_test_employee): - """Попытка удалить несуществующее событие.""" - # Arrange - employee = create_test_employee( - username='noevents', - email='noevents@example.com', - first_name='No', - last_name='Events', - birth_date=date(1995, 8, 30) - ) - - # Act - result = DeleteBirthdayEventService.execute({'employee': employee}) - - # Assert - assert result['success'] is False - assert result['deleted_count'] == 0 - - -@pytest.mark.django_db -class TestBulkSyncBirthdaysService: - """Тесты для массовой синхронизации дней рождений.""" - - def test_bulk_sync_creates_events_for_all_employees(self, create_test_employee): - """Массовая синхронизация создает события для всех сотрудников.""" - # Arrange - employees_data = [ - ('user1', 'Alice', 'Johnson', date(1990, 1, 10)), - ('user2', 'Bob', 'Williams', date(1985, 5, 20)), - ('user3', 'Charlie', 'Brown', date(1992, 9, 15)), - ] - - for username, first, last, birth in employees_data: - create_test_employee( - username=username, - email=f'{username}@example.com', - first_name=first, - last_name=last, - birth_date=birth - ) - - # Act - result = BulkSyncBirthdaysService.execute({}) - - # Assert - assert result['total'] == 3 - assert result['created'] == 3 - assert result['updated'] == 0 - assert result['skipped'] == 0 - assert len(result['errors']) == 0 - - # Проверяем, что события созданы - assert Event.objects.filter(title__startswith='🎂 День рождения:').count() == 3 - - def test_bulk_sync_updates_existing_events(self, create_test_employee): - """Массовая синхронизация обновляет существующие события.""" - # Arrange - employee = create_test_employee( - username='existing', - email='existing@example.com', - first_name='Existing', - last_name='User', - birth_date=date(1987, 6, 12) - ) - - # Создаем событие заранее - UpsertBirthdayEventService.execute({'employee': employee}) - - # Act - result = BulkSyncBirthdaysService.execute({}) - - # Assert - assert result['total'] == 1 - assert result['created'] == 0 - assert result['updated'] == 1 - assert result['skipped'] == 0 - - def test_bulk_sync_skips_employees_without_birth_date(self, create_test_employee): - """Пропускает сотрудников без даты рождения.""" - # Arrange - create_test_employee( - username='withbd', - email='withbd@example.com', - first_name='With', - last_name='BirthDate', - birth_date=date(1990, 1, 1) - ) - - create_test_employee( - username='nobd', - email='nobd@example.com', - first_name='No', - last_name='BirthDate', - birth_date=None - ) - - # Act - result = BulkSyncBirthdaysService.execute({}) - - # Assert - assert result['total'] == 2 - assert result['created'] == 1 - assert result['skipped'] == 1 - - def test_bulk_sync_handles_errors_gracefully(self, create_test_employee): - """Обрабатывает ошибки без падения всего процесса.""" - # Arrange - create_test_employee( - username='valid', - email='valid@example.com', - first_name='Valid', - last_name='Employee', - birth_date=date(1993, 3, 5) - ) - - # Act - result = BulkSyncBirthdaysService.execute({}) - - # Assert - assert result['total'] >= 1 - assert result['created'] + result['updated'] >= 1 - # Часть может быть успешной даже если есть ошибки diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py deleted file mode 100644 index e9fb2b6f..00000000 --- a/backend/employees/urls_front.py +++ /dev/null @@ -1,85 +0,0 @@ -# backend/employees/urls_front.py -from django.urls import path - -from . import views_front - -app_name = "employees" - -urlpatterns = [ - # --- Профиль и сотрудники --- - path("list/", views_front.employee_list, name="employees_list"), - path("me/", views_front.employee_profile, name="profile"), - path("me/edit/", views_front.employee_edit_me, name="employee_edit_me"), - path("/", views_front.employee_profile, name="employee_detail"), - path("/edit/", views_front.employee_edit, name="employee_edit"), - path("create/", views_front.employee_create, name="employee_create"), - path( - "create/modal/", - views_front.employee_create_modal, - name="employee_create_modal" - ), - - # --- Группы --- - path("employees//groups/bulk/", views_front.employee_groups_bulk, name="employee_groups_bulk"), - path("groups/create/", views_front.group_create, name="group_create"), - path("groups/delete/", views_front.group_delete, name="group_delete"), - # --- Отделы --- - path("departments/create/", views_front.department_create, name="department_create"), - path( - "departments//roles/edit/", - views_front.edit_department_role, - name="edit_department_role", - ), - path("departments/", views_front.department_list, name="department_list"), - path( - "departments//", views_front.department_detail, name="department_detail" - ), - path( - "departments//edit/", - views_front.department_edit, - name="department_edit", - ), - path( - "departments//set-head/", - views_front.department_set_head, - name="department_set_head", - ), - path( - "/departments//set-role/", - views_front.set_member_role, - name="set_member_role", - ), - path( - "departments//add-member/", - views_front.department_add_member, - name="department_add_member", - ), - path( - "departments//remove-member/", - views_front.department_remove_member, - name="department_remove_member", - ), - # --- Навыки --- - path("/skill/add/", views_front.skill_add, name="skill_add"), - path("/skill/remove/", views_front.skill_remove, name="skill_remove"), - # --- Должности --- - path( - "positions/create/", views_front.position_create_front, name="position_create" - ), - path( - "positions//update/", - views_front.position_update_front, - name="position_update", - ), - path( - "/set-position/", - views_front.employee_set_position_front, - name="employee_set_position", - ), - path( - "me/set-position/", - views_front.employee_set_position_me_front, - name="employee_set_position_me", - ), - path("positions//", views_front.position_detail_front, name="position_detail"), -] diff --git a/backend/employees/urls_front_auth.py b/backend/employees/urls_front_auth.py deleted file mode 100644 index 62035775..00000000 --- a/backend/employees/urls_front_auth.py +++ /dev/null @@ -1,18 +0,0 @@ -# backend\eusrr_backend\urls_front_auth.py -from django.urls import path - -from . import views_auth - -app_name = "auth_front" - -urlpatterns = [ - path("login/", views_auth.EmailOrPhoneLoginView.as_view(), name="login"), - path("logout/", views_auth.ConfirmLogoutView.as_view(), name="logout"), - path("register/", views_auth.register_view, name="register"), - path("verify-email/", views_auth.verify_email_view, name="verify_email"), - path("resend-email/", views_auth.resend_email_view, name="resend_email"), - path("password-reset/", views_auth.PasswordResetView.as_view(), name="password_reset"), - path("password-reset/done/", views_auth.PasswordResetDoneView.as_view(), name="password_reset_done"), - path("reset///", views_auth.PasswordResetConfirmView.as_view(), name="password_reset_confirm"), - path("reset/done/", views_auth.PasswordResetCompleteView.as_view(), name="password_reset_complete"), -] \ No newline at end of file diff --git a/backend/employees/views_auth.py b/backend/employees/views_auth.py deleted file mode 100644 index 1a870920..00000000 --- a/backend/employees/views_auth.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -import logging -from api.client import ApiClient, get_api_client -from django.contrib import messages -from django.contrib.auth import logout -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.views import LoginView as DjangoLoginView -from django.contrib.auth.views import \ - PasswordResetCompleteView as DjangoPasswordResetCompleteView -from django.contrib.auth.views import \ - PasswordResetConfirmView as DjangoPasswordResetConfirmView -from django.contrib.auth.views import \ - PasswordResetDoneView as DjangoPasswordResetDoneView -from django.contrib.auth.views import \ - PasswordResetView as DjangoPasswordResetView -from django.shortcuts import redirect, render -from django.urls import reverse -from django.utils.http import urlencode -from django.views import View -from django.views.decorators.http import require_http_methods - -from .forms_auth import (EmailOrPhoneAuthenticationForm, RegistrationForm, - VerifyEmailForm, CustomPasswordResetForm) - -logger = logging.getLogger(__name__) - - -def _apply_api_errors_to_form(form, data: dict): - """Развешиваем ошибки из API по полям/неполям.""" - if not isinstance(data, dict): - form.add_error(None, "Неизвестная ошибка сервера.") - return - - if "ok" in data and not data.get("ok"): - form.add_error( - None, data.get("error") or data.get("detail") or "Ошибка запроса." - ) - - detail = data.get("detail") - if isinstance(detail, str): - form.add_error(None, detail) - elif isinstance(detail, (list, tuple)): - for msg in detail: - form.add_error(None, msg) - - for field, errs in data.items(): - if field in {"ok", "error", "detail"}: - continue - target = "phone_number" if field == "phone" else field - if target in form.fields: - if isinstance(errs, (list, tuple)): - for e in errs: - form.add_error(target, e) - else: - form.add_error(target, str(errs)) - else: - if isinstance(errs, (list, tuple)): - for e in errs: - form.add_error(None, f"{field}: {e}") - else: - form.add_error(None, f"{field}: {errs}") - - -@require_http_methods(["GET", "POST"]) -def register_view(request): - """ - Страница регистрации: показываем Django-форму и шлём валидные данные в /api/v1/auth/register/ - (аватар пока локально, в API не отправляем — загрузим после верификации). - """ - api = get_api_client(request) - - if request.method == "GET": - form = RegistrationForm() - return render(request, "auth/register.html", {"form": form}) - - # ВАЖНО: учитывать файлы (чтобы form.avatar был BoundField) - form = RegistrationForm(request.POST, request.FILES) - - # Логируем полученные данные - logger.warning(f"[REGISTER_VIEW] POST data: {request.POST.dict()}") - logger.warning(f"[REGISTER_VIEW] FILES: {list(request.FILES.keys())}") - - if not form.is_valid(): - logger.warning(f"[REGISTER_VIEW] Form errors: {form.errors}") - return render(request, "auth/register.html", {"form": form}, status=400) - - payload = form.to_api_payload() # формирует json для API, включая password - logger.warning(f"[REGISTER_VIEW] API payload keys: {list(payload.keys())}") - payload_debug = {k: v for k, v in payload.items() if k != 'avatar'} - logger.warning(f"[REGISTER_VIEW] API payload (без avatar): {payload_debug}") - logger.warning(f"[REGISTER_VIEW] API base URL: {api.cfg.base_url}") - - resp = api.post("v1/auth/register/", json=payload) - logger.warning(f"[REGISTER_VIEW] API response status: {resp.status}") - resp_data = resp.json if resp.json else resp.text - logger.warning(f"[REGISTER_VIEW] API response: {resp_data}") - - if resp.status == 201: - messages.success( - request, - "Мы отправили код подтверждения на вашу почту. Введите его на следующем шаге.", - ) - q = urlencode({"email": payload["email"]}) - return redirect(f'{reverse("auth_front:verify_email")}?{q}') - - data = resp.json or {} - if resp.status == 200 and data.get("ok") and data.get("pending_verification"): - messages.info(request, "Аккаунт уже создан, подтвердите email кодом из письма.") - q = urlencode({"email": payload["email"]}) - return redirect(f'{reverse("auth_front:verify_email")}?{q}') - - # Остальные ошибки — показать в форме - _apply_api_errors_to_form(form, data) - return render(request, "auth/register.html", {"form": form}, status=400) - - -@require_http_methods(["GET", "POST"]) -def verify_email_view(request): - """Ввод кода подтверждения. Использует /api/v1/auth/verify-email/.""" - api = get_api_client(request) - - if request.method == "GET": - form = VerifyEmailForm(initial={"email": request.GET.get("email", "")}) - return render(request, "auth/verify_email.html", {"form": form}) - - form = VerifyEmailForm(request.POST) - if not form.is_valid(): - return render(request, "auth/verify_email.html", {"form": form}, status=400) - - payload = {"email": form.cleaned_data["email"], "code": form.cleaned_data["code"]} - r = api.post("v1/auth/verify-email/", json=payload) - - if r.ok and (r.json or {}).get("ok"): - messages.success(request, "Email подтверждён! Теперь вы можете войти.") - return redirect("auth_front:login") - - data = r.json or {} - err = data.get("error") or data.get("detail") or "Не удалось подтвердить email." - if err == "invalid_code": - err = "Неверный код подтверждения." - elif err == "user_not_found": - err = "Пользователь с таким email не найден." - elif err == "ldap_record_missing": - err = "Ошибка синхронизации с LDAP. Обратитесь к администратору." - form.add_error(None, err) - return render(request, "auth/verify_email.html", {"form": form}, status=400) - - -@require_http_methods(["POST"]) -def resend_email_view(request): - """Кнопка «Отправить код ещё раз» — /api/v1/auth/resend-email/.""" - api = get_api_client(request) - email = (request.POST.get("email") or "").strip() - r = api.post("v1/auth/resend-email/", json={"email": email}) - - if r.ok and (r.json or {}).get("ok"): - messages.success(request, "Код подтверждения повторно отправлен на вашу почту.") - else: - data = r.json or {} - err = ( - data.get("error") - or data.get("detail") - or "Не удалось отправить код повторно." - ) - if err == "already_verified": - err = "Email уже подтверждён." - elif err == "user_not_found": - err = "Пользователь с таким email не найден." - messages.error(request, err) - - q = urlencode({"email": email}) if email else "" - return redirect(f'{reverse("auth_front:verify_email")}{("?" + q) if q else ""}') - - -class EmailOrPhoneLoginView(DjangoLoginView): - """ - Страница входа: одно поле 'Email или телефон' + пароль. - После успешной Django-аутентификации — берём JWT у DRF и кладём в сессию. - """ - - template_name = "auth/login.html" - authentication_form = EmailOrPhoneAuthenticationForm - - def form_valid(self, form): - # 1) Django-логин - response = super().form_valid(form) - # 2) Получаем JWT от API и кладём его в сессию - user = self.request.user - password = form.cleaned_data.get("password") - client = ApiClient(self.request) - ok = client.login(user.email, password) # USERNAME_FIELD=email - if not ok: - messages.error( - self.request, "Не удалось получить токен API. Повторите вход позже." - ) - return response - - -class ConfirmLogoutView(LoginRequiredMixin, View): - template_name = "auth/logout.html" - - def get(self, request): - next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or "" - return render(request, self.template_name, {"next": next_url}) - - def post(self, request): - next_url = request.POST.get("next") or reverse("auth_front:login") - logout(request) - messages.success(request, "Вы вышли из аккаунта.") - return redirect(next_url) - - -# ===== Сброс пароля ===== - - -class PasswordResetView(DjangoPasswordResetView): - template_name = "auth/password_reset.html" - email_template_name = "auth/password_reset_email.txt" - html_email_template_name = "emails/password_reset_email.html" - subject_template_name = "auth/password_reset_subject.txt" - success_url = "/auth/password-reset/done/" - form_class = CustomPasswordResetForm - - def post(self, request, *args, **kwargs): - """Переопределяем post для логирования""" - logger.info( - f"\n{'='*80}\n" - f"[PasswordResetView.post] 🔑 ЗАПРОС НА ВОССТАНОВЛЕНИЕ ПАРОЛЯ\n" - f" Email/телефон: {request.POST.get('email', '?')}\n" - f" IP: {request.META.get('REMOTE_ADDR')}\n" - f"{'='*80}" - ) - - return super().post(request, *args, **kwargs) - - def form_valid(self, form): - """Логирование успешной валидации формы""" - logger.info( - f"[PasswordResetView.form_valid] ✅ Форма валидна, email={form.cleaned_data.get('email')}" - ) - - return super().form_valid(form) - - -class PasswordResetDoneView(DjangoPasswordResetDoneView): - template_name = "auth/password_reset_done.html" - - -class PasswordResetConfirmView(DjangoPasswordResetConfirmView): - template_name = "auth/password_reset_confirm.html" - success_url = "/auth/reset/done/" - - -class PasswordResetCompleteView(DjangoPasswordResetCompleteView): - template_name = "auth/password_reset_complete.html" diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py deleted file mode 100644 index 83e40230..00000000 --- a/backend/employees/views_front.py +++ /dev/null @@ -1,1741 +0,0 @@ -# backend/employees/views_front.py - -from __future__ import annotations - -import base64 -import json -import mimetypes -from io import BytesIO -from typing import Any, Dict, Tuple - -from api.client import get_api_client -from api.decorators import require_api_auth -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, JsonResponse -from django.http.multipartparser import MultiPartParser, MultiPartParserError -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.csrf import csrf_protect -from django.views.decorators.http import require_http_methods, require_POST - -from .forms_front import DepartmentEditForm, SetHeadForm - -# ========================= -# Утилиты -# ========================= - - -def _file_to_data_uri(uploaded) -> str: - """ - Превращаем загруженный файл (InMemoryUploadedFile/TemporaryUploadedFile) - в data URI (data:image/...;base64,xxxx). - Deprecated: используйте прямую отправку файлов через multipart/form-data. - """ - if not uploaded: - return "" - content = uploaded.read() - if hasattr(uploaded, "seek"): - uploaded.seek(0) - mime = ( - getattr(uploaded, "content_type", None) - or mimetypes.guess_type(getattr(uploaded, "name", ""))[0] - or "application/octet-stream" - ) - b64 = base64.b64encode(content).decode("ascii") - return f"data:{mime};base64,{b64}" - - -def _parse_multipart_request( - request: HttpRequest, logger -) -> tuple[Dict[str, Any], Any]: - """Возвращает (POST, FILES) даже для PATCH с multipart/form-data.""" - content_type = request.content_type or "" - if request.method == "POST" or "multipart/form-data" not in content_type: - return request.POST, request.FILES - - try: - stream = BytesIO(request.body) - parser = MultiPartParser( - request.META, - stream, - request.upload_handlers, - request.encoding, - ) - post_data, files_data = parser.parse() - logger.info( - "[multipart_parser] Parsed PATCH payload: %s fields, %s files", - len(post_data.keys()), - len(files_data.keys()), - ) - return post_data, files_data - except MultiPartParserError as exc: - logger.error( - "[multipart_parser] Failed to parse multipart body: %s", - exc, - ) - except Exception: - logger.exception( - "[multipart_parser] Unexpected error while parsing multipart body" - ) - return request.POST, request.FILES - - -def _extract_items(payload: Any): - """ - Универсальный парсер: пагинированный ответ DRF или «сырой» список. - """ - if isinstance(payload, dict) and "results" in payload: - return ( - payload["results"], - payload.get("count", 0), - payload.get("next"), - payload.get("previous"), - ) - if isinstance(payload, list): - return payload, len(payload), None, None - return [], 0, None, None - - -def _convert_api_url_to_frontend(api_url: str | None, frontend_path: str) -> str | None: - """ - Преобразует API URL пагинации в frontend URL. - - Пример: - http://localhost:9000/api/v1/employees/?page=2&search=test - -> /employees/list/?page=2&search=test - """ - if not api_url: - return None - - from urllib.parse import parse_qs, urlencode, urlparse - - parsed = urlparse(api_url) - query_params = parse_qs(parsed.query) - - # Преобразуем query_params обратно в строку (parse_qs возвращает списки) - clean_params = { - k: v[0] if isinstance(v, list) and len(v) == 1 else v - for k, v in query_params.items() - } - query_string = urlencode(clean_params, doseq=True) - - return f"{frontend_path}?{query_string}" if query_string else frontend_path - return [], 0, None, None - - -def _extract_results(payload): - if isinstance(payload, dict): - return payload.get("results", payload.get("items", [])) or [] - return payload or [] - - -def _fetch_employee_posts(api, employee_id: int): - # сначала пробуем ?author=, если в API другое имя фильтра — fallback на ?employee= - for url in ( - f"v1/posts/?author={employee_id}&ordering=-created_at", - f"v1/posts/?employee={employee_id}&ordering=-created_at", - ): - r = api.get(url) - if r.ok: - data = r.json or [] - return _extract_results(data) - - -def _fetch_department_posts(api, department_id: int) -> list[dict]: - """Возвращает публикации отдела (type=department), отсортированные по пину и дате. - - Args: - api: Инициализированный API-клиент (get_api_client(request)). - department_id (int): ID отдела. - - Returns: - list[dict]: Список публикаций для шаблона. - - Raises: - RuntimeError: Если ответ API не JSON (редко, мы перехватываем безопасно). - ValueError: Если department_id некорректный (не int). - """ - # Пробуем явный фильтр по типу и отделу; fallback — только department - for url in ( - f"v1/posts/?type=department&department={int(department_id)}&ordering=-pinned,-created_at", - f"v1/posts/?department={int(department_id)}&ordering=-pinned,-created_at", - ): - r = api.get(url) - if r.ok: - data = r.json or [] - return _extract_results(data) - return [] - - -def _json_body(request: HttpRequest) -> Dict[str, Any]: - """Безопасно парсим JSON; если не JSON — возвращаем пустой dict.""" - try: - if request.body: - return json.loads(request.body.decode("utf-8")) - except Exception: - pass - return {} - - -def _error(message: str, status: int = 400) -> JsonResponse: - return JsonResponse({"detail": message}, status=status) - - -def _ok(data: Dict[str, Any], status: int = 200) -> JsonResponse: - return JsonResponse(data, status=status) - - -def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]: - """Унифицированная распаковка ответа API.""" - try: - data = resp.json or {} - except Exception: - data = {} - return resp.ok, data, getattr(resp, "status", 500) - - -# ========================= -# Сотрудники -# ========================= - - -# ---------- LIST ---------- -@require_api_auth -def employee_list(request): - """ - Список сотрудников. - Если ?format=json — вернуть JSON (для автокомплита): - /employees/list/?format=json&q=...&exclude=1,2,3&limit=12 - - Для HTML страницы - данные загружаются через JavaScript на клиенте. - """ - # Если параметр active отсутствует и это HTML-запрос, - # редиректим с active=true по умолчанию - if request.GET.get("format") != "json" and "active" not in request.GET: - params = request.GET.copy() - params["active"] = "true" - from django.http import QueryDict - - query_string = params.urlencode() - return redirect(f"{request.path}?{query_string}") - - api = get_api_client(request) - - # JSON-ветка для автокомплита - if request.GET.get("format") == "json": - page = request.GET.get("page") or 1 - ordering = request.GET.get("o") or "last_name" - q = (request.GET.get("q") or "").strip() - department = request.GET.get("department") - position = request.GET.get("position") - is_active = request.GET.get("is_active") - - params = {"page": page, "ordering": ordering} - if q: - params["search"] = q - if department: - params["department"] = department - if position: - params["position"] = position - if is_active: - params["is_active"] = is_active - - resp = api.get("v1/employees/", params=params) - if not resp.ok: - return JsonResponse({"results": [], "count": 0}, status=resp.status or 500) - - payload = resp.json or {} - items, count, next_url, prev_url = _extract_items(payload) - - # exclude: "1,2,3" - raw_ex = (request.GET.get("exclude") or "").strip() - try: - ex_ids = {int(x) for x in raw_ex.split(",") if x.strip().isdigit()} - except Exception: - ex_ids = set() - - try: - limit = int(request.GET.get("limit") or 12) - except Exception: - limit = 12 - - # отфильтруем и обрежем на стороне фронт-прокси - def _id(e): - try: - return int(e.get("id")) - except Exception: - return -1 - - items = [e for e in items if _id(e) not in ex_ids][:limit] - return JsonResponse({"results": items, "count": len(items)}) - - # HTML-ветка - НЕ загружаем данные сотрудников, они загрузятся через JavaScript - # Получаем только справочники для фильтров - departments_list = [] - positions_list = [] - try: - # Получаем все отделы для фильтров - dept_resp = api.get("v1/departments/", params={"page_size": 1000}) - if dept_resp.ok: - dept_data = dept_resp.json or {} - departments_list = dept_data.get("results", []) - - # Получаем все должности для фильтров - pos_resp = api.get("v1/positions/", params={"page_size": 1000}) - if pos_resp.ok: - pos_data = pos_resp.json or {} - positions_list = pos_data.get("results", []) - except Exception: - pass - - context = { - "employees": [], # Пустой список - данные загрузятся через JS - "departments_list": departments_list, - "positions_list": positions_list, - } - return render(request, "employees/employees_list.html", context) - - -# ---------- DETAIL / ME ---------- -@require_api_auth -def employee_profile(request, pk: str | int = "me"): - """Профиль сотрудника (/employees/me/ и /employees//). - - Поведение: - * Если pk совпадает с текущим пользователем — выполняется 302 редирект на /employees/me/?. - * Подтягивает данные сотрудника, посты, справочники (навыки/должности/группы). - * Передаёт в шаблон персональные группы сотрудника для рендера «пилюль». - * Передаёт `endpoint` для PATCH-редактирования профиля через фронтовую вьюху. - - Args: - request (HttpRequest): HTTP-запрос. - pk (str | int, optional): 'me'/'self' или числовой ID сотрудника. По умолчанию 'me'. - - Returns: - HttpResponse: Отрисованный шаблон профиля либо редирект/сообщение об ошибке. - - Raises: - None: Все ошибки бэкенд-API обрабатываются и отражаются через messages/redirect. - """ - # --- редирект "свой id" -> /me --- - try: - int_pk = int(pk) # type: ignore[arg-type] - except (TypeError, ValueError): - int_pk = None - - if ( - int_pk is not None - and request.user.is_authenticated - and request.user.id == int_pk - ): - url = reverse("employees:profile") - qs = request.META.get("QUERY_STRING") - if qs: # сохраняем ?tab=skills и т.п. - url = f"{url}?{qs}" - return redirect(url) # 302 достаточно, чтобы не кэшировать навсегда - - # --- обычная логика detail/me --- - api = get_api_client(request) - is_me = str(pk) in {"me", "self"} - - endpoint_api = "v1/employees/me/" if is_me else f"v1/employees/{pk}/" - resp = api.get(endpoint_api) - if not resp.ok: - messages.error(request, f"Сотрудник не найден ({resp.status})") - return redirect("employees:employees_list") - - emp = resp.json or {} - emp_id = emp.get("id") or (request.user.id if is_me else pk) - posts = _fetch_employee_posts(api, emp_id) - - # Справочники - sresp = api.get("v1/skills/", params={"ordering": "name"}) - sdata = sresp.json if sresp.ok else [] - all_skills = sdata.get("results", sdata) if isinstance(sdata, dict) else sdata - - gresp_all = api.get("v1/groups/", params={"ordering": "name"}) - gdata_all = gresp_all.json if gresp_all.ok else [] - all_groups = ( - gdata_all.get("results", gdata_all) - if isinstance(gdata_all, dict) - else gdata_all - ) or [] - - # Персональные группы сотрудника (для «пилюль») - pg_resp = api.get("v1/groups/", params={"ordering": "name", "member": emp_id}) - pg_data = pg_resp.json if pg_resp.ok else [] - emp_personal_groups = ( - pg_data.get("results", pg_data) if isinstance(pg_data, dict) else pg_data - ) or [] - - presp = api.get("v1/positions/", params={"ordering": "name"}) - pdata = presp.json if presp.ok else [] - all_positions = pdata.get("results", pdata) if isinstance(pdata, dict) else pdata - - # Отделы/роли - emp_depts = [] - for d in emp.get("departments") or []: - did = d.get("id") - if not did: - emp_depts.append(d) - continue - - # узнаём head отдела - dresp = api.get(f"v1/departments/{did}/") - head_id = None - if dresp.ok: - dj = dresp.json or {} - head = dj.get("head") or {} - head_id = head.get("id") or dj.get("head_id") - - # подгружаем роли отдела - rresp = api.get( - "v1/department-roles/", params={"department": did, "ordering": "name"} - ) - roles = [] - if rresp.ok: - rj = rresp.json - roles = (rj.get("results", rj) if isinstance(rj, dict) else rj) or [] - - can_edit_role = ( - bool(head_id) - and str(head_id) == str(getattr(request.user, "id", None)) - and not d.get("is_head") # нельзя править главу - and str(emp_id) != str(getattr(request.user, "id", None)) # не себе - ) - - d2 = dict(d) - d2.update( - { - "roles_choices": roles, # [{id, name, ...}] - "can_edit_role": can_edit_role, # bool - } - ) - emp_depts.append(d2) - - # Права на редактирование профиля - can_edit = ( - True - if is_me - else bool( - request.user and (request.user.is_staff or request.user.id == emp.get("id")) - ) - ) - - # ВАЖНО: endpoint фронтовой вьюхи для PATCH профиля (а не прямой DRF-URL) - endpoint = ( - reverse("employees:employee_edit_me") - if is_me - else reverse("employees:employee_edit", args=[emp_id]) - ) - - ctx = { - "emp": emp, - "emp_depts": emp_depts, - "can_edit": can_edit, - "all_skills": all_skills, - "all_positions": all_positions, - "all_groups": all_groups, - "emp_personal_groups": emp_personal_groups, - "posts": posts, - "is_me": True if is_me else False, - "endpoint": endpoint, # для data-endpoint в _employee_edit.html - } - return render(request, "employees/employee_detail.html", ctx) - - -# ---------- EDIT (self or staff) ---------- -@require_api_auth -@require_http_methods(["PATCH", "POST"]) -def employee_edit(request, pk: int): - """ - Редактирование пользователя по id. - Поддерживает как JSON, так и multipart/form-data (для загрузки файлов). - """ - import logging - - logger = logging.getLogger(__name__) - - api = get_api_client(request) - - # Проверяем Content-Type - content_type = request.content_type or "" - logger.info(f"[employee_edit] pk={pk}, Content-Type: {content_type}") - parsed_post, parsed_files = _parse_multipart_request(request, logger) - logger.info( - "[employee_edit] request.POST keys: %s", - list(parsed_post.keys()), - ) - logger.info( - "[employee_edit] request.FILES keys: %s", - list(parsed_files.keys()), - ) - - if "multipart/form-data" in content_type: - # Обрабатываем multipart/form-data (файлы) - logger.info("[employee_edit] Processing multipart/form-data") - payload = {} - - # Собираем обычные поля из POST - for key, value in parsed_post.items(): - if key == "csrfmiddlewaretoken": - continue - # Если это массив (например skills_ids) - if key.endswith("_ids") or key == "skills": - payload[key] = parsed_post.getlist(key) - else: - payload[key] = value - - logger.info( - "[employee_edit] Payload before avatar: %s", - list(payload.keys()), - ) - - # Обрабатываем файлы (аватар) - if "avatar" in parsed_files: - avatar_file = parsed_files["avatar"] - logger.info( - "[employee_edit] Processing avatar: %s, size: %s", - avatar_file.name, - avatar_file.size, - ) - else: - logger.warning("[employee_edit] No avatar in request.FILES") - - logger.info(f"[employee_edit] Final payload keys: {list(payload.keys())}") - - # Отправляем в DRF (multipart если есть файл, иначе JSON) - try: - if avatar_file: - files = { - "avatar": ( - avatar_file.name, - avatar_file.read(), - avatar_file.content_type, - ) - } - resp = api.patch(f"v1/employees/{pk}/", data=payload, files=files) - else: - resp = api.patch(f"v1/employees/{pk}/", json=payload) - logger.info(f"[employee_edit] API response status: {resp.status}") - except Exception as e: - logger.error(f"[employee_edit] API call error: {e}") - return JsonResponse({"detail": f"API error: {str(e)}"}, status=500) - else: - # Обрабатываем JSON (как раньше) - logger.info("[employee_edit] Processing JSON") - try: - payload = json.loads(request.body or b"{}") - except Exception as e: - logger.error(f"[employee_edit] JSON parse error: {e}") - payload = {} - - try: - resp = api.patch(f"v1/employees/{pk}/", json=payload) - except Exception as e: - logger.error(f"[employee_edit] API call error: {e}") - return JsonResponse({"detail": f"API error: {str(e)}"}, status=500) - - try: - data = resp.json - except Exception as e: - logger.error(f"[employee_edit] Response JSON parse error: {e}") - data = {"detail": resp.text} - - # Всегда возвращаем JSON без редиректа - return JsonResponse(data or {}, status=resp.status) - - -# ---------- EDIT ME ---------- -@require_api_auth -@require_http_methods(["PATCH", "POST"]) -def employee_edit_me(request): - """ - Редактирование собственного профиля через /api/v1/employees/me/ - Поддерживает как JSON, так и multipart/form-data (для загрузки файлов). - """ - import logging - - logger = logging.getLogger(__name__) - - logger.info("=" * 80) - logger.info("[employee_edit_me] ===== FUNCTION CALLED =====") - logger.info(f"[employee_edit_me] Method: {request.method}") - - api = get_api_client(request) - - # Проверяем Content-Type - content_type = request.content_type or "" - logger.info(f"[employee_edit_me] Content-Type: {content_type}") - parsed_post, parsed_files = _parse_multipart_request(request, logger) - logger.info( - "[employee_edit_me] request.POST keys: %s", - list(parsed_post.keys()), - ) - logger.info( - "[employee_edit_me] request.FILES keys: %s", - list(parsed_files.keys()), - ) - - if "multipart/form-data" in content_type: - # Обрабатываем multipart/form-data (файлы) - logger.info("[employee_edit_me] Processing multipart/form-data") - payload = {} - - # Собираем обычные поля из POST - for key, value in parsed_post.items(): - value_len = len(value) if hasattr(value, "__len__") else "N/A" - logger.info( - f"[employee_edit_me] POST field: '{key}' = '{value}' " - f"(type: {type(value).__name__}, len: {value_len})" - ) - if key == "csrfmiddlewaretoken": - continue - # Пропускаем пустое поле avatar (приходит из FormData без файла) - if key == "avatar" and value == "": - logger.info("[employee_edit_me] ✅ Skipping empty avatar field") - continue - # Конвертируем пустые строки в null для ForeignKey полей - if key.endswith("_id") and value == "": - value = None - # Если это массив (например skills_ids) - if key.endswith("_ids") or key == "skills": - payload[key] = parsed_post.getlist(key) - else: - payload[key] = value - - logger.info( - "[employee_edit_me] Payload before avatar: %s", - list(payload.keys()), - ) - - # Обрабатываем файлы (аватар) - if "avatar" in parsed_files: - avatar_file = parsed_files["avatar"] - logger.info( - "[employee_edit_me] Processing avatar: %s, size: %s", - avatar_file.name, - avatar_file.size, - ) - else: - logger.warning("[employee_edit_me] No avatar in request.FILES") - - logger.info( - "[employee_edit_me] Final payload keys: %s", - list(payload.keys()), - ) - - # Отправляем в DRF (multipart если есть файл, иначе JSON) - try: - if avatar_file: - files = { - "avatar": ( - avatar_file.name, - avatar_file.read(), - avatar_file.content_type, - ) - } - resp = api.patch("v1/employees/me/", data=payload, files=files) - else: - resp = api.patch("v1/employees/me/", json=payload) - logger.info( - "[employee_edit_me] API response status: %s", - resp.status, - ) - except Exception as e: - logger.error(f"[employee_edit_me] API call error: {e}") - return JsonResponse({"detail": f"API error: {str(e)}"}, status=500) - else: - # Обрабатываем JSON (как раньше) - logger.info("[employee_edit_me] Processing JSON") - try: - payload = json.loads(request.body or b"{}") - except Exception as e: - logger.error(f"[employee_edit_me] JSON parse error: {e}") - payload = {} - - try: - resp = api.patch("v1/employees/me/", json=payload) - except Exception as e: - logger.error(f"[employee_edit_me] API call error: {e}") - return JsonResponse({"detail": f"API error: {str(e)}"}, status=500) - - try: - data = resp.json - except Exception as e: - logger.error(f"[employee_edit_me] Response JSON parse error: {e}") - data = {"detail": resp.text} - - # Всегда возвращаем JSON без редиректа - return JsonResponse(data or {}, status=resp.status) - - -# ---------- CREATE (staff) ---------- -@require_api_auth -@require_http_methods(["GET", "POST"]) -def employee_create(request): - """Страница создания сотрудника (старый вариант)""" - api = get_api_client(request) - - if request.method == "GET": - return render(request, "employees/employee_create.html") - - data = { - "email": request.POST.get("email", "").strip(), - "phone_number": request.POST.get("phone_number", "").strip(), - "last_name": request.POST.get("last_name", "").strip(), - "first_name": request.POST.get("first_name", "").strip(), - "patronymic": request.POST.get("patronymic", "").strip(), - "telegram": request.POST.get("telegram", "").strip(), - "whatsapp": request.POST.get("whatsapp", "").strip(), - "wechat": request.POST.get("wechat", "").strip(), - } - - # Обработка gender (должно быть числом) - gender = request.POST.get("gender", "").strip() - if gender: - try: - data["gender"] = int(gender) - except (ValueError, TypeError): - pass - - # Обработка birth_date - birth_date = request.POST.get("birth_date", "").strip() - if birth_date: - data["birth_date"] = birth_date - - # Обработка position - position = request.POST.get("position", "").strip() - if position: - try: - data["position"] = int(position) - except (ValueError, TypeError): - pass - - skills = request.POST.getlist("skills") - if skills: - data["skills"] = skills - password = request.POST.get("password", "").strip() - if password: - data["password"] = password # API сгенерирует сам, если не передали - - # Если есть аватар - multipart, иначе JSON - files = {} - if request.FILES.get("avatar"): - avatar_file = request.FILES["avatar"] - files["avatar"] = ( - avatar_file.name, - avatar_file.read(), - avatar_file.content_type, - ) - - if files: - resp = api.post("v1/employees/", data=data, files=files) - else: - resp = api.post("v1/employees/", json=data) - if not resp.ok: - messages.error(request, f"Не удалось создать: {resp.status} — {resp.text}") - return render(request, "employees/employee_create.html", {"form": request.POST}) - - new_emp = resp.json or {} - messages.success(request, "Сотрудник создан.") - return redirect("employees:employee_detail", pk=new_emp.get("id")) - - -@login_required -@require_http_methods(["POST"]) -def employee_create_modal(request): - """ - Обработчик создания сотрудника через модальное окно. - Принимает AJAX запрос, обращается к API через фронтовую логику. - Возвращает JSON с результатом. - """ - api = get_api_client(request) - - # Собираем данные из формы - data = { - "email": request.POST.get("email", "").strip(), - "phone_number": request.POST.get("phone_number", "").strip(), - "last_name": request.POST.get("last_name", "").strip(), - "first_name": request.POST.get("first_name", "").strip(), - "patronymic": request.POST.get("patronymic", "").strip(), - "telegram": request.POST.get("telegram", "").strip(), - "whatsapp": request.POST.get("whatsapp", "").strip(), - "wechat": request.POST.get("wechat", "").strip(), - } - - # Обработка gender (должно быть числом: 0, 1, 2) - gender = request.POST.get("gender", "").strip() - if gender: - try: - data["gender"] = int(gender) - except (ValueError, TypeError): - data["gender"] = None - - # Обработка остальных полей - birth_date = request.POST.get("birth_date", "").strip() - if birth_date: - data["birth_date"] = birth_date - - position = request.POST.get("position", "").strip() - if position: - try: - data["position"] = int(position) - except (ValueError, TypeError): - pass - - # Обработка навыков (если будут добавлены в форму) - skills = request.POST.getlist("skills") - if skills: - data["skills"] = skills - - # Обработка пароля - password = request.POST.get("password", "").strip() - if password: - data["password"] = password - - # Если есть аватар - отправляем multipart, иначе JSON - files = {} - if request.FILES.get("avatar"): - avatar_file = request.FILES["avatar"] - files["avatar"] = ( - avatar_file.name, - avatar_file.read(), - avatar_file.content_type, - ) - - # Отправляем запрос к API - if files: - # С файлом - multipart/form-data - resp = api.post("v1/employees/", data=data, files=files) - else: - # Без файла - JSON - resp = api.post("v1/employees/", json=data) - - if not resp.ok: - # Парсим ошибки от API - error_msg = "Не удалось создать сотрудника" - field_errors = {} - - try: - error_data = resp.json - if isinstance(error_data, dict): - # Логируем для отладки - import logging - - logger = logging.getLogger(__name__) - logger.error(f"API validation errors: {error_data}, sent data: {data}") - - # Проверяем, есть ли детальные ошибки по полям - for field, messages_list in error_data.items(): - if isinstance(messages_list, list): - field_errors[field] = messages_list - else: - field_errors[field] = [str(messages_list)] - except Exception as e: - import logging - - logger = logging.getLogger(__name__) - logger.error(f"Error parsing API response: {e}, resp.text: {resp.text}") - error_msg = f"Ошибка {resp.status}: {resp.text}" - - # Если есть детальные ошибки, возвращаем их - if field_errors: - return JsonResponse( - { - "success": False, - "error": "Проверьте правильность заполнения полей", - "errors": field_errors, - }, - status=400, - ) - else: - return JsonResponse({"success": False, "error": error_msg}, status=400) - - # Успешное создание - new_emp = resp.json or {} - emp_id = new_emp.get("id") - - redirect_url = None - if emp_id: - redirect_url = reverse("employees:employee_detail", kwargs={"pk": emp_id}) - - return JsonResponse( - { - "success": True, - "message": "Сотрудник успешно создан", - "employee_id": emp_id, - "redirect_url": redirect_url, - } - ) - - -# ========================= -# Группы -# ========================= - - -@require_api_auth -@require_http_methods(["POST"]) -def employee_groups_bulk(request, pk: int): - """ - Назначить/снять персональные группы сотруднику. - Body: { "action": "add"|"remove", "group_ids": [1,2,3] } - """ - api = get_api_client(request) - try: - payload = json.loads(request.body or b"{}") - except Exception: - payload = {} - action = (payload.get("action") or "").strip().lower() - group_ids = payload.get("group_ids") or [] - if action not in {"add", "remove"}: - return JsonResponse({"detail": "action must be 'add' or 'remove'"}, status=400) - try: - gids = [int(x) for x in group_ids] - except Exception: - return JsonResponse( - {"detail": "group_ids must be a list of integers"}, status=400 - ) - if not gids: - return JsonResponse({"detail": "group_ids is empty"}, status=400) - - ok, errors = 0, {} - endpoint = "add-members" if action == "add" else "remove-members" - for gid in gids: - r = api.post(f"v1/groups/{gid}/{endpoint}/", json={"member_ids": [pk]}) - if r.ok: - ok += 1 - else: - try: - j = r.json or {} - msg = j.get("detail") or r.text or f"HTTP {r.status}" - except Exception: - msg = r.text or f"HTTP {r.status}" - errors[str(gid)] = msg - status_code = 200 if not errors else 207 # Multi-Status семантически уместен - return JsonResponse( - {"ok": True, "processed": ok, "failed": errors}, status=status_code - ) - - -@require_api_auth -@require_http_methods(["POST"]) -def group_create(request): - """ - Создать группу (LDAP+БД) через DRF. - Body: { "name": "CN-Name" } - """ - api = get_api_client(request) - try: - payload = json.loads(request.body or b"{}") - except Exception: - payload = {} - name = (payload.get("name") or "").strip() - if not name: - return JsonResponse({"detail": "name is required"}, status=400) - - resp = api.post("v1/groups/", json={"name": name}) - try: - data = resp.json - except Exception: - data = {"detail": resp.text} - return JsonResponse(data or {}, status=resp.status) - - -@require_api_auth -@require_http_methods(["POST"]) -def group_delete(request): - """ - Удалить группу (LDAP+БД) через DRF. - Body: { "group_id": 123 } - """ - api = get_api_client(request) - try: - payload = json.loads(request.body or b"{}") - except Exception: - payload = {} - gid = payload.get("group_id") - try: - gid = int(gid) - except Exception: - return JsonResponse({"detail": "group_id must be int"}, status=400) - - resp = api.delete(f"v1/groups/{gid}/") - if not resp.ok: - return JsonResponse({"detail": resp.text}, status=resp.status) - return JsonResponse({"ok": True, "id": gid}, status=200) - - -# ========================= -# Отделы -# ========================= - - -# ---------- LIST ---------- -@require_api_auth -def department_list(request): - """ - Список отделов. - Ходит в /api/v1/departments/ c поддержкой поиска/сортировки/пагинации DRF. - """ - api = get_api_client(request) - - page = request.GET.get("page") or 1 - ordering = request.GET.get("o") or "name" - q = (request.GET.get("q") or "").strip() - - params = {"page": page, "ordering": ordering} - if q: - params["search"] = q # DRF SearchFilter - - resp = api.get("v1/departments/", params=params) - if not resp.ok: - messages.error(request, f"Ошибка API ({resp.status})") - return render( - request, - "employees/department_list.html", - { - "departments": [], - "count": 0, - "q": q, - "o": ordering, - "page": page, - "next_url": None, - "prev_url": None, - }, - ) - - payload = resp.json # важно: без "or {}" — иначе потеряем список - - # ✅ поддерживаем оба формата DRF: dict с results или list без пагинации - if isinstance(payload, dict) and "results" in payload: - items = payload["results"] - count = payload.get("count", len(items)) - next_url = payload.get("next") - prev_url = payload.get("previous") - elif isinstance(payload, list): - items = payload - count = len(items) - next_url = prev_url = None - else: - items = [] - count = 0 - next_url = prev_url = None - - # Преобразуем API URLs в frontend URLs - next_url = _convert_api_url_to_frontend(next_url, request.path) - prev_url = _convert_api_url_to_frontend(prev_url, request.path) - - context = { - "departments": items, - "count": count, - "q": q, - "o": ordering, - "page": page, - "next_url": next_url, - "prev_url": prev_url, - } - return render(request, "employees/department_list.html", context) - - -# ---------- DETAIL ---------- -@require_api_auth -def department_detail(request, pk: int): - """ - Страница отдела. - - Тонкая фронт-вьюха: забирает агрегированный контекст у API - /api/v1/departments/{id}/ui-context/, дополняет его минимальными - метаданными для шаблона и рендерит страницу. - - В контекст добавляются: - - members_exclude_ids: список id сотрудников, которых нельзя - предлагать в "Добавить участника" (те, кто уже в отделе + руководитель) - - can_manage / can_change_head / can_assign_roles: плоские флаги, - чтобы шаблонам не ходить в вложенный user_perms. - """ - api = get_api_client(request) - r = api.get(f"v1/departments/{pk}/ui-context/") - if not r.ok: - messages.error( - request, - "Отдел не найден" if r.status == 404 else f"Ошибка API ({r.status})", - ) - return redirect("employees:department_list") - - # r.json в нашем api-клиенте может быть полем или методом — поддержим оба варианта - try: - ctx = ( - r.json - if isinstance(r.json, dict) - else (r.json() if callable(r.json) else {}) - ) - except Exception: - ctx = {} - - if not isinstance(ctx, dict): - ctx = {} - - perms = ( - (ctx.get("user_perms") or {}) if isinstance(ctx.get("user_perms"), dict) else {} - ) - - # ——— метаданные для автодополнения «Добавить участника» ——— - links = ctx.get("links") or [] - exclude_ids: list[int] = [] - for li in links: - emp = li.get("employee") if isinstance(li, dict) else None - if emp is None: - continue - # в агрегате employee может быть int (id) или объект - eid = emp.get("id") if isinstance(emp, dict) else emp - if eid: - try: - exclude_ids.append(int(eid)) - except (TypeError, ValueError): - pass - - # head тоже исключаем - dept = ctx.get("dept") or {} - head_id = dept.get("head_id") or ( - (dept.get("head") or {}).get("id") - if isinstance(dept.get("head"), dict) - else None - ) - if head_id: - try: - exclude_ids.append(int(head_id)) - except (TypeError, ValueError): - pass - - # уберём дубликаты, сохраняя порядок - seen = set() - exclude_ids = [x for x in exclude_ids if not (x in seen or seen.add(x))] - - ctx.update( - { - "members_exclude_ids": exclude_ids, - "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), - } - ) - - # Добавляем бюджет отдела на текущий квартал - from django.utils import timezone - from procurement.models import Budget - now = timezone.now() - current_quarter = (now.month - 1) // 3 + 1 - try: - department_budget = Budget.objects.get( - department_id=pk, - year=now.year, - quarter=current_quarter - ) - except Budget.DoesNotExist: - department_budget = None - - ctx["department_budget"] = department_budget - ctx["budget_year"] = now.year - ctx["budget_quarter"] = current_quarter - - return render(request, "employees/department_detail.html", ctx) - - -# ---------- EDIT BASIC FIELDS ---------- -@require_api_auth -@require_http_methods(["POST"]) -def department_edit(request, pk: int): - """ - Редактирование name/description через PATCH /api/v1/departments/{id}/. - Права проверяет API (manage_department). - """ - api = get_api_client(request) - form = DepartmentEditForm(request.POST) - if not form.is_valid(): - messages.error(request, "Проверьте форму") - return redirect("employees:department_detail", pk=pk) - - payload = { - "name": form.cleaned_data["name"], - "description": form.cleaned_data.get("description") or "", - } - resp = api.patch(f"v1/departments/{pk}/", json=payload) - if not resp.ok: - messages.error( - request, - f"Не удалось обновить отдел ({resp.status}) — {resp.json or resp.text}", - ) - else: - messages.success(request, "Отдел обновлён") - 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 getattr(r, "status", None) == 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 getattr(r, "status", None) == 403: - form.add_error(None, "Недостаточно прав для создания отдела.") - status_code = 403 - else: - form.add_error( - None, f"Ошибка API ({getattr(r, 'status', None)}). Повторите попытку позже." - ) - 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"]) -def department_set_head(request, pk: int): - """ - Назначение/снятие руководителя через POST /api/v1/departments/{id}/set_head/. - Права проверяет API (change_department_head). - """ - api = get_api_client(request) - form = SetHeadForm(request.POST) - if not form.is_valid(): - messages.error(request, "Проверьте форму") - return redirect("employees:department_detail", pk=pk) - - head_id = form.cleaned_data.get("head_id", None) - payload = {"head_id": head_id if head_id is not None else None} # None → снять - resp = api.post(f"v1/departments/{pk}/set_head/", json=payload) - if not resp.ok: - messages.error( - request, - f"Не удалось изменить руководителя ({resp.status}) — {resp.json or resp.text}", - ) - else: - messages.success( - request, "Руководитель обновлён" if head_id else "Руководитель снят" - ) - return redirect("employees:department_detail", pk=pk) - - -# ---------- SET MEMBER ROLE ---------- -@require_api_auth -@require_http_methods(["POST"]) -def set_member_role(request, dept_id: int, emp_id: int): - """ - Серверный proxy: меняем роль сотрудника в отделе. - Тело: role_id (пусто => снять роль), next (опционально для редиректа). - """ - api = get_api_client(request) - - role_id_raw = request.POST.get("role_id", "") - role_id = None - if str(role_id_raw).strip() not in {"", "None", "null"}: - try: - role_id = int(role_id_raw) - except (TypeError, ValueError): - role_id = None - - # заодно не даём менять самому себе (UI тоже скрывает) - if (str(emp_id) == str(getattr(request.user, "id", None))) and not ( - request.user.is_staff or request.user.is_superuser - ): - messages.error(request, "Нельзя изменять собственную роль в отделе.") - return redirect( - request.POST.get("next") or reverse("employees:profile", args=("me",)) - ) - - # пробрасываем в API — он сам проверит, что вы глава отдела - resp = api.post( - f"v1/departments/{dept_id}/set_member_role/", - json={ - "employee_id": emp_id, - "role_id": role_id, - }, - ) - - if resp.ok: - messages.success(request, "Роль обновлена.") - else: - try: - data = resp.json - except Exception: - data = {} - err = data.get("detail") or data.get("error") or f"HTTP {resp.status}" - messages.error(request, f"Не удалось обновить роль: {err}") - - return redirect( - request.POST.get("next") - or reverse("employees:employee_profile", args=(emp_id,)) - ) - - -@require_api_auth -@require_http_methods(["POST"]) -def department_add_member(request, pk: int): - """ - Прокси-вьюха: добавляет (активирует) сотрудника в отделе через API. - - Ожидает: - POST form-data: employee_id - - Поведение: - - Делает POST в /api/v1/departments/{id}/add_member/ c {"employee_id": }. - - Сообщения об успехе/ошибке через django.contrib.messages. - - Редирект обратно на страницу отдела. - """ - api = get_api_client(request) - - raw = (request.POST.get("employee_id") or "").strip() - try: - employee_id = int(raw) - except (TypeError, ValueError): - messages.error(request, "Не выбран сотрудник.") - return redirect("employees:department_detail", pk=pk) - - resp = api.post( - f"v1/departments/{pk}/add_member/", json={"employee_id": employee_id} - ) - if resp.ok: - messages.success(request, "Сотрудник добавлен в отдел.") - else: - # аккуратно вытащим текст ошибки - detail = "" - try: - data = resp.json or {} - detail = data.get("detail") or data.get("error") or "" - except Exception: - detail = resp.text or "" - messages.error(request, f"Не удалось добавить: {resp.status} {detail}".strip()) - - return redirect("employees:department_detail", pk=pk) - - -@require_api_auth -@require_http_methods(["POST"]) -def department_remove_member(request, pk: int): - """ - Прокси-вьюха: деактивирует (удаляет) сотрудника из отдела через API. - - Ожидает: - POST form-data: employee_id - - Поведение: - - Делает POST в /api/v1/departments/{id}/remove_member/ c {"employee_id": }. - - Не изменяет роль (эта логика на API и отделена). - - Сообщения об успехе/ошибке через django.contrib.messages. - - Редирект обратно на страницу отдела. - """ - api = get_api_client(request) - - raw = (request.POST.get("employee_id") or "").strip() - try: - employee_id = int(raw) - except (TypeError, ValueError): - messages.error(request, "Не выбран сотрудник.") - return redirect("employees:department_detail", pk=pk) - - resp = api.post( - f"v1/departments/{pk}/remove_member/", json={"employee_id": employee_id} - ) - if resp.ok: - messages.success(request, "Сотрудник удалён из отдела.") - else: - try: - data = resp.json or {} - detail = data.get("detail") or data.get("error") or "" - except Exception: - detail = resp.text or "" - messages.error(request, f"Не удалось удалить: {resp.status} {detail}".strip()) - - return redirect("employees:department_detail", pk=pk) - - -@require_api_auth -@require_POST -def edit_department_role(request, pk: int): - """ - Универсальный обработчик ролей отдела: - - op=create : POST v1/department-roles/ {department: pk, name} + сразу POST set_perms/ - - op=update : PATCH v1/department-roles// {name} + POST set_perms/ - - op=delete : DELETE v1/department-roles// - """ - api = get_api_client(request) - op = (request.POST.get("op") or "").lower().strip() - - # Быстрая проверка прав (как и в шаблоне) - dept_resp = api.get(f"v1/departments/{pk}/") - if not dept_resp.ok: - messages.error(request, "Отдел не найден.") - return redirect("employees:department_detail", pk=pk) - dept = dept_resp.json - allowed = request.user.is_staff or ( - dept.get("head") and dept["head"].get("id") == request.user.id - ) - if not allowed: - messages.error(request, "Недостаточно прав для управления ролями отдела.") - return redirect("employees:department_detail", pk=pk) - - try: - if op == "create": - name = (request.POST.get("name") or "").strip() - if not name: - messages.error(request, "Название роли не может быть пустым.") - return redirect("employees:department_detail", pk=pk) - - # 1) создаём роль - resp = api.post( - "v1/department-roles/", json={"department": pk, "name": name} - ) - if resp.ok: - # 2) сразу выставляем права, если они пришли из формы - role_id = (resp.json or {}).get("id") - perm_ids = [ - int(x) - for x in request.POST.getlist("permission_ids") - if str(x).isdigit() - ] - if role_id is not None: - # ВАЖНО: у set_perms — хвостовой слэш! - api.post( - f"v1/department-roles/{role_id}/set_perms/", - json={"permission_ids": perm_ids}, - ) - - messages.success(request, f"Роль «{name}» создана.") - else: - messages.error( - request, - f"Ошибка создания роли: {getattr(resp, 'status', '')} {resp.text or ''}", - ) - - elif op == "update": - role_id = request.POST.get("role_id") - name = (request.POST.get("name") or "").strip() - - # 1) имя - if name: - api.patch(f"v1/department-roles/{role_id}/", json={"name": name}) - - # 2) права (с хвостовым слэшем!) - perm_ids = [ - int(x) - for x in request.POST.getlist("permission_ids") - if str(x).isdigit() - ] - resp = api.post( - f"v1/department-roles/{role_id}/set_perms/", - json={"permission_ids": perm_ids}, - ) - - if resp.ok: - messages.success(request, f"Роль обновлена: «{name}».") - else: - messages.error( - request, - f"Ошибка обновления роли: {getattr(resp, 'status', '')} {resp.text or ''}", - ) - - elif op == "delete": - role_id = request.POST.get("role_id") - if not role_id: - messages.error(request, "Не указан идентификатор роли для удаления.") - return redirect("employees:department_detail", pk=pk) - - resp = api.delete(f"v1/department-roles/{role_id}/") - if resp.ok or getattr(resp, "status", None) == 204: - messages.success(request, "Роль удалена.") - else: - messages.error( - request, - f"Ошибка удаления роли: {getattr(resp, 'status', '')} {resp.text or ''}", - ) - - else: - messages.error(request, "Неизвестная операция.") - except Exception as exc: - messages.error(request, f"Ошибка: {exc}") - - return redirect("employees:department_detail", pk=pk) - - -# ========================= -# Навыки -# ========================= - - -@login_required -@require_POST -@require_api_auth -def skill_add(request, pk: int): - api = get_api_client(request) - skill_id = request.POST.get("skill_id") - skill_name = (request.POST.get("skill_name") or "").strip() - payload = { - k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v - } - resp = api.post(f"v1/employees/{pk}/add_skill/", data=payload) - return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500) - - -@login_required -@require_POST -@require_api_auth -def skill_remove(request, pk: int): - api = get_api_client(request) - skill_id = request.POST.get("skill_id") - skill_name = (request.POST.get("skill_name") or "").strip() - payload = { - k: v for k, v in (("skill_id", skill_id), ("skill_name", skill_name)) if v - } - # NB: у DRF-экшена underscore: remove_skill - resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload) - return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500) - - -# ========================= -# Должности -# ========================= - - -# ---------- CREATE POSITION ---------- -@csrf_protect -@login_required -@require_POST -def position_create_front(request: HttpRequest) -> JsonResponse: - """ - Создать должность (прокси к POST /api/v1/positions/). - Ожидает JSON или form-data с полями: name, description?, groups? (list[int]). - """ - payload = _json_body(request) or request.POST.dict() - name = (payload.get("name") or "").strip() - description = (payload.get("description") or "").strip() - - # groups может прийти как список, как csv или отсутствовать - groups = payload.get("groups") - if isinstance(groups, str): - groups = [int(x) for x in groups.split(",") if x.strip().isdigit()] - elif isinstance(groups, list): - groups = [int(x) for x in groups if str(x).isdigit()] - else: - groups = [] - - if not name: - return _error("Укажите название должности", 400) - - api = get_api_client(request) - api_payload = {"name": name} - if description: - api_payload["description"] = description - if groups: - api_payload["groups"] = groups - - resp = api.post("v1/positions/", json=api_payload) - ok, data, status = _api_ok_or_error(resp) - if not ok: - return JsonResponse( - data or {"detail": "Не удалось создать должность"}, status=status - ) - return _ok(data, status=201) - - -@require_api_auth -@require_http_methods(["GET"]) -def position_detail_front(request: HttpRequest, pos_id: int) -> JsonResponse: - """ - Прокси к GET /api/v1/positions/{id}/ — возвращает JSON должности. - """ - api = get_api_client(request) - resp = api.get(f"v1/positions/{pos_id}/") - ok, data, status = _api_ok_or_error(resp) - if not ok: - return JsonResponse( - data or {"detail": "Не удалось получить должность"}, status=status - ) - return JsonResponse(data or {}, status=200) - - -# ---------- UPDATE POSITION ---------- -@csrf_protect -@login_required -@require_http_methods(["PATCH", "POST"]) -def position_update_front(request: HttpRequest, pos_id: int) -> JsonResponse: - """ - Обновить должность (прокси к PATCH /api/v1/positions/{id}/). - Приходит JSON с любыми изменяемыми полями (name, description, groups). - Разрешаем метод PATCH и POST (для совместимости) — на POST тоже шлём PATCH в API. - """ - payload = _json_body(request) or request.POST.dict() - - # Если groups пришли строкой — распарсим - groups = payload.get("groups", None) - if isinstance(groups, str): - payload["groups"] = [int(x) for x in groups.split(",") if x.strip().isdigit()] - - api = get_api_client(request) - resp = api.patch(f"v1/positions/{pos_id}/", json=payload) - ok, data, status = _api_ok_or_error(resp) - if not ok: - return JsonResponse( - data or {"detail": "Не удалось обновить должность"}, status=status - ) - return _ok(data) - - -# ---------- ASSIGN POSITION TO EMPLOYEE ---------- -@csrf_protect -@login_required -@require_POST -def employee_set_position_front(request: HttpRequest, emp_id: int) -> JsonResponse: - """ - Назначить сотруднику position_id (прокси к PATCH /api/v1/employees/{id}/). - Ожидает JSON/form-data: position_id (int или пусто для снятия). - """ - try: - payload = json.loads(request.body.decode("utf-8") or "{}") - except json.JSONDecodeError: - return JsonResponse({"detail": "Bad JSON"}, status=400) - - raw = payload.get("position_id", None) - - try: - position_id = int(str(raw).strip()) if raw is not None else None - except (ValueError, TypeError): - return JsonResponse({"detail": "position_id must be an integer"}, status=400) - - if not position_id: - return JsonResponse({"detail": "position_id is required"}, status=400) - - api = get_api_client(request) - resp = api.patch(f"v1/employees/{emp_id}/", json={"position_id": position_id}) - ok, data, status = _api_ok_or_error(resp) - if not ok: - return JsonResponse( - data or {"detail": "Не удалось назначить должность"}, status=status - ) - return _ok(data) - - -# Удобная обёртка под «/me/» -@csrf_protect -@login_required -@require_POST -def employee_set_position_me_front(request: HttpRequest) -> JsonResponse: - """ - Назначить должность текущему пользователю (прокси к PATCH /api/v1/employees/me/). - """ - try: - payload = json.loads(request.body.decode("utf-8") or "{}") - except json.JSONDecodeError: - return JsonResponse({"detail": "Bad JSON"}, status=400) - - raw = payload.get("position_id", None) - - try: - position_id = int(str(raw).strip()) if raw is not None else None - except (ValueError, TypeError): - return JsonResponse({"detail": "position_id must be an integer"}, status=400) - - if not position_id: - return JsonResponse({"detail": "position_id is required"}, status=400) - - api = get_api_client(request) - resp = api.patch("v1/employees/me/", json={"position_id": position_id}) - ok, data, status = _api_ok_or_error(resp) - if not ok: - return JsonResponse( - data or {"detail": "Не удалось назначить должность"}, status=status - ) - return _ok(data) diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index f6fb0a75..4631e071 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -27,19 +27,13 @@ def serve_service_worker(request): urlpatterns = [ path("admin/", admin.site.urls), - path("auth/", include("employees.urls_front_auth", namespace="auth_front")), path("documents/", include("documents.urls", namespace="documents")), - path("employees/", include("employees.urls_front", namespace="employees")), path("communications/", include("communications.urls", namespace="communications")), - path("notifications/", include("notifications.urls", namespace="notifications")), - path("search/", include("search.urls", namespace="search")), - path("finance/", include("finance.urls", namespace="finance")), path("api/", include(("api.urls", "api"), namespace="api")), # Service Worker должен быть в корне для правильного scope path("sw.js", serve_service_worker, name="sw"), # django-filer URLs для обработки приватных файлов (с проверкой прав доступа) path("", include("filer.server.urls")), - path("", include("feed.urls_front", namespace="feed")), ] # Публичные медиа файлы (без проверки прав) - только в режиме разработки diff --git a/backend/finance/tests.py b/backend/finance/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/finance/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/finance/urls.py b/backend/finance/urls.py deleted file mode 100644 index cff07e5d..00000000 --- a/backend/finance/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""URL configuration for finance app.""" - -from django.urls import path - -from . import views - -app_name = "finance" - -urlpatterns = [ - path("", views.finance_dashboard, name="dashboard"), -] diff --git a/backend/finance/views.py b/backend/finance/views.py deleted file mode 100644 index 9adf5c99..00000000 --- a/backend/finance/views.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Views for finance app.""" - -from django.contrib.auth.decorators import login_required -from django.shortcuts import render - - -@login_required -def finance_dashboard(request): - """ - Отображает финансовую панель с встроенным Яндекс.Диском. - """ - context = { - "yandex_disk_url": "https://disk.yandex.ru/i/0JWL-v5zyWJi0Q" - } - return render(request, "finance/dashboard.html", context) diff --git a/backend/search/models.py b/backend/search/models.py deleted file mode 100644 index 71a83623..00000000 --- a/backend/search/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/backend/search/tests.py b/backend/search/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/search/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/search/urls.py b/backend/search/urls.py deleted file mode 100644 index e32871d2..00000000 --- a/backend/search/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from .views import search_view - -app_name = "search" - -urlpatterns = [ - path("", search_view, name="search"), -] diff --git a/backend/search/views.py b/backend/search/views.py deleted file mode 100644 index 7cb31342..00000000 --- a/backend/search/views.py +++ /dev/null @@ -1,158 +0,0 @@ -# backend/search/views.py -""" -Полнотекстовый поиск через django-watson. - -Заменяет предыдущую реализацию с ручными Q-объектами на -автоматическую индексацию watson. -""" -from __future__ import annotations - -from typing import Any, Dict, List, Literal, TypedDict -from collections import Counter - -from django.contrib.auth.decorators import login_required -from django.http import HttpRequest, HttpResponse -from django.shortcuts import render -from django.utils.html import escape -from watson.search import search as watson_search - -from feed.models import Post -from employees.models import Employee, Department -from requests_app.models import Request - - -class SearchItem(TypedDict): - """Элемент единой выдачи поиска. - - Attributes: - model_name (Literal["post","employee","department","request","chat","message","event"]): - Тип найденной сущности. - object (Any): Экземпляр модели, который будет отрисован в шаблоне. - """ - - model_name: Literal[ - "post", "employee", "department", "request", "chat", "message", "event" - ] - object: Any - - -# ------------------------ ВСПОМОГАТЕЛЬНЫЕ УТИЛИТЫ ------------------------ - - -def _is_hr(user: Employee) -> bool: - """Проверяет расширенные права на просмотр заявлений. - - Args: - user (Employee): Текущий пользователь. - - Returns: - bool: True, если пользователь может видеть все заявления. - - Notes: - Используются пермишены из requests_app. - """ - return user.has_perm("requests_app.can_view_all_requests") or user.has_perm( - "requests_app.can_process_requests" - ) - -def _get_model_name(obj: Any) -> str: - """Определяет тип модели для SearchItem. - - Args: - obj: Объект модели из результатов поиска. - - Returns: - str: Название типа модели (post, employee, department, etc.) - """ - model = type(obj) - model_name = model.__name__.lower() - - # Маппинг моделей на типы для SearchItem - mapping = { - "post": "post", - "employee": "employee", - "department": "department", - "request": "request", - "chat": "chat", - "message": "message", - "calendarevent": "event", - } - - return mapping.get(model_name, model_name) - - -# -------------------------------- ВЬЮХА -------------------------------- - - -@login_required -def search_view(request: HttpRequest) -> HttpResponse: - """Единый поиск по системе через django-watson. - - Args: - request (HttpRequest): Запрос (`GET["q"]`). - - Returns: - HttpResponse: Рендер `templates/search/results.html` с плоским списком. - - Контекст шаблона: - - query: str — исходная строка запроса - - results: List[SearchItem] — плоский список элементов - - counts: Dict[str, int] — счётчики по типам: - keys: post, employee, department, request, chat, message, event - - total: int — сумма всех счётчиков - """ - raw_q = request.GET.get("q", "") or "" - query = escape(raw_q.strip()) - - items: List[SearchItem] = [] - counts: Dict[str, int] = { - "post": 0, - "employee": 0, - "department": 0, - "request": 0, - "chat": 0, - "message": 0, - "event": 0, - } - - if query: - # Выполняем поиск через watson - search_results = watson_search(query) - - # Группируем по типам моделей - by_model: Dict[str, List[Any]] = {} - for result in search_results: - obj = result.object - model_name = _get_model_name(obj) - - # Фильтрация заявлений по правам доступа - if model_name == "request": - is_hr = _is_hr(request.user) # type: ignore[arg-type] - # Проверяем, что объект - это действительно Request с полем employee - if not is_hr and hasattr(obj, 'employee') and obj.employee != request.user: # type: ignore[attr-defined] - continue # Пропускаем чужие заявления - - if model_name not in by_model: - by_model[model_name] = [] - by_model[model_name].append(obj) - - # Формируем итоговый список с ограничением 10 элементов на тип - for model_name in ["post", "employee", "department", "request", - "chat", "message", "event"]: - objects = by_model.get(model_name, []) - counts[model_name] = len(objects) - - # Берем первые 10 для отображения - for obj in objects[:10]: - items.append({ - "model_name": model_name, # type: ignore[typeddict-item] - "object": obj - }) - - ctx = { - "query": query, - "results": items, - "counts": counts, - "total": sum(counts.values()), - } - return render(request, "search/results.html", ctx)