Corp/backend/employees/models.py
Игорь Надеин 5af1f9ec91 feat(api): регистрация/верификация через API, whitelist в middleware, iOS-экран подтверждения выхода
**Что сделано**

* **API**

  * Добавлен `POST /api/v1/auth/register/` — создание пользователя, генерация кода и письмо (повторная отправка, если email есть, но не подтверждён).
  * `POST /api/v1/auth/verify-email/` возвращает `user_id` при успехе.
  * `POST /api/v1/auth/resend-email/` — без изменений по контракту.
* **Фронт (views\_front)**

  * `RegisterView.form_valid` теперь вызывает API `auth/register` (без прямой работы с ORM).
  * `EmailVerifyView.post` переводён на API `auth/verify-email` + автологин при успехе.
  * Повторная отправка кода (`resend_email`) уже через API, добавлена защита от частых запросов по сессии.
  * Исправлена сериализация телефона (E.164/строка) при отправке в API.
* **Клиент API**

  * Новый модуль `employees/api_client.py`: безопасная сериализация payload, удобные ошибки (`api_bad_json status=… body=…`) и абсолютные URL через `request.build_absolute_uri`.
* **Middleware**

  * `AuthRequiredMiddleware` теперь **пропускает**: `/api/v1/**`, статику/медиа, страницы логина/регистрации/сброса пароля, админ-логин и `OPTIONS`. Остальное для анонима — редирект на `login`.
* **Аутентификация/выход**

  * Страница подтверждения выхода (в стиле iOS bottom sheet) + `ConfirmLogoutView`. Пункт «Выйти» ведёт на GET `/auth/logout/`, далее POST подтверждения.
  * Шаблон выхода принимает `next` из контекста (без обращений к `request.GET.next` в шаблоне).
* **UX/мелочи**

  * Устранены 500 при отсутствии `password_reset` — добавлены/учтены URL’ы потока сброса (или безопасные ссылки).
  * Уточнены сообщения об ошибках при регистрации/верификации.

**Как проверить**

1. Аноним может вызвать:

   * `POST /api/v1/auth/register/` → `201 {"ok":true}` / `200 {"ok":true,"resent":true}` / `400 {"error":"email_taken"}`.
   * `POST /api/v1/auth/verify-email/` с корректным кодом → `200 {"ok":true,"user_id":...}`.
   * `POST /api/v1/auth/resend-email/` → `200 {"ok":true}`.
2. UI:

   * Регистрация в форме → обращение к API, письмо с кодом, переход на ввод кода.
   * Ввод кода → API, автологин, редирект в ленту.
   * «Выйти» в меню → страница подтверждения → POST → сообщение «Вы вышли…» и редирект на логин/`next`.
3. Проверить, что `/api/v1/**` не редиректится на логин (middleware whitelist работает).
4. Телефон в форме не ломает JSON (нет `TypeError: PhoneNumber is not JSON serializable`).

**Миграции**: нет.
2025-08-20 17:49:38 +03:00

382 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend\employees\models.py
from django.conf import settings
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models
from django.utils import timezone
from django.utils.crypto import get_random_string
from phonenumber_field.modelfields import PhoneNumberField
from simple_history.models import HistoricalRecords
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES
class DateRangeMixin(models.Model):
class Meta:
abstract = True
def clean(self):
super().clean()
if hasattr(self, "date_from") and hasattr(self, "date_to"):
if self.date_from and self.date_to and self.date_from >= self.date_to:
raise ValidationError("Дата начала должна быть раньше даты окончания")
class EmployeeManager(BaseUserManager):
use_in_migrations = True
def create_user(self, credential, password=None, **extra_fields):
# Определяем, email ли это или phone
if "@" in credential:
email = self.normalize_email(credential)
phone_number = extra_fields.pop("phone_number", None)
else:
phone_number = credential
email = extra_fields.pop("email", None)
if not email:
raise ValueError("Пользователь должен иметь email для подтверждения")
if not phone_number:
raise ValueError("Пользователь должен иметь номер телефона")
user = self.model(email=email, phone_number=phone_number, **extra_fields)
user.set_password(password)
user.email_verified = False # Требует подтверждения
user.email_activation_code = get_random_string(
6, allowed_chars="0123456789"
) # Криптостойкий код
user.save(using=self._db)
# Отправка confirmation email (в будущем асинхронно через bots/celery)
send_mail(
"Подтверждение регистрации",
f"Ваш код подтверждения: {user.email_activation_code}",
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
return user
def create_superuser(self, credential, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("email_verified", True) # Суперюзер confirmed
if not extra_fields.get("is_staff"):
raise ValueError("Суперпользователь должен иметь is_staff=True")
if not extra_fields.get("is_superuser"):
raise ValueError("Суперпользователь должен иметь is_superuser=True")
return self.create_user(credential, password, **extra_fields)
def get_active(self):
"""
Возвращает list активных сотрудников, оптимизированный: предварительная фильтрация в DB по полям,
затем Python-фильтр для сложной логики (actions).
"""
qs = (
self.get_queryset()
.filter(email_verified=True, is_active=True)
.prefetch_related("actions")
.order_by("-id")
)
return [
e for e in qs if e.is_actually_active
] # Финальный фильтр по actions в Python
class Employee(AbstractUser):
username = None
gender = models.PositiveSmallIntegerField(
"Пол", choices=GENDER_CHOICES, default=0, blank=True
)
avatar = models.ImageField("Фото", upload_to="users/avatars", blank=True, null=True)
phone_number = PhoneNumberField("Номер телефона", max_length=100, unique=True)
patronymic = models.CharField("Отчество", max_length=100, blank=True)
birth_date = models.DateField("Дата рождения", blank=True, null=True)
email = models.EmailField("email address", unique=True)
telegram = models.CharField("Telegram", max_length=100, blank=True)
whatsapp = PhoneNumberField("WhatsApp", blank=True)
wechat = models.CharField("WeChat", max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
skills = models.ManyToManyField("Skill", related_name="employees", blank=True)
is_active = models.BooleanField(default=False)
sms_activation_code = models.CharField(
max_length=6, blank=True, null=True
) # Для будущей SMS-активации в bots (fallback к email)
email_verified = models.BooleanField("Email подтверждён", default=False)
email_activation_code = models.CharField(max_length=6, blank=True, null=True)
objects = EmployeeManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["first_name", "last_name", "phone_number"]
class Meta:
verbose_name = "Сотрудник"
verbose_name_plural = "Сотрудники"
ordering = ("last_name", "first_name")
indexes = [
models.Index(fields=["last_name", "first_name"]),
models.Index(fields=["email"]),
models.Index(fields=["phone_number"]),
]
def clean(self):
super().clean()
if not (self.whatsapp or self.telegram or self.wechat):
raise ValidationError(
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
code="contact_required",
)
def __str__(self):
return f"{self.last_name} {self.first_name}"
@property
def employment_status(self):
last_action = self.actions.order_by("-date").first()
if last_action:
return last_action.get_action_display()
return "Нет данных"
@property
def departments(self):
# Отделы, где сотрудник активен
department_ids = EmployeeDepartment.objects.filter(
employee=self, is_active=True
).values_list("department_id", flat=True)
# Плюс отделы, где сотрудник начальник
head_ids = Department.objects.filter(head=self).values_list("id", flat=True)
return Department.objects.filter(
models.Q(id__in=department_ids) | models.Q(id__in=head_ids)
).distinct()
@property
def is_actually_active(self):
if not self.email_verified:
return False # Неактивен без подтверждения email
last_action = self.actions.order_by("-date").first()
if not last_action:
return self.is_active
return last_action.action != ACTION_DISMISSED
def verify_email(self, code):
if self.email_activation_code == code:
self.email_verified = True
self.email_activation_code = None
self.is_active = True # Активируем после подтверждения
self.save()
return True
return False
class EmployeeAction(models.Model):
employee = models.ForeignKey(
Employee, related_name="actions", on_delete=models.CASCADE
)
action = models.CharField(
"Кадровое событие", max_length=50, choices=ACTION_CHOICES
) # Увеличено для расширения
date = models.DateTimeField("Дата действия")
comment = models.TextField("Комментарий/причина", blank=True)
extra = models.JSONField("Дополнительно", blank=True, null=True)
history = HistoricalRecords()
class Meta:
verbose_name = "Кадровое событие"
verbose_name_plural = "Кадровые события"
ordering = ["-date"]
def __str__(self):
return f"{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}"
class Department(models.Model):
name = models.CharField("Название отдела", max_length=255, unique=True)
description = models.TextField("Описание", blank=True)
head = models.ForeignKey(
"Employee",
on_delete=models.SET_NULL,
verbose_name="Руководитель отдела",
related_name="headed_departments",
null=True,
blank=True,
)
head_appointed_at = models.DateTimeField(
"Дата назначения руководителя", auto_now=True
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Отдел"
verbose_name_plural = "Отделы"
def __str__(self):
return self.name
@property
def active_employees(self):
"""
Все активные сотрудники отдела (через EmployeeDepartment),
включая руководителя, если он есть и активен.
"""
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
if self.head and self.head.is_actually_active and self.head not in employees:
employees.append(self.head)
return employees
@property
def new_employees(self):
"""
Новые сотрудники отдела за последний месяц (по дате присоединения).
"""
month_ago = timezone.now() - timezone.timedelta(days=30)
qs = self.employeedepartment.filter(
is_active=True, date_from__gte=month_ago
).select_related("employee")
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
# Добавляем руководителя, если он недавно назначен и активен
if (
self.head
and self.head_appointed_at >= month_ago
and self.head.is_actually_active
and self.head not in employees
):
employees.append(self.head)
return employees
class EmployeeDepartment(DateRangeMixin, models.Model):
employee = models.ForeignKey(
"Employee", on_delete=models.CASCADE, related_name="departments_links"
)
department = models.ForeignKey(
Department, on_delete=models.CASCADE, related_name="employeedepartment"
)
role = models.CharField("Роль в отделе", max_length=100, blank=True)
date_from = models.DateField("Дата начала работы", null=True, blank=True)
date_to = models.DateField("Дата окончания работы", null=True, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ("employee", "department")
verbose_name = "Принадлежность к отделу"
verbose_name_plural = "Принадлежности к отделам"
def __str__(self):
return f"{self.employee} в {self.department} ({self.role or 'сотрудник'})"
class EmployeePosition(DateRangeMixin, models.Model):
"""
Опциональная модель для хранения названия должности.
Не влияет на определение, в каком отделе сотрудник работает.
"""
employee = models.ForeignKey(
"Employee", on_delete=models.CASCADE, related_name="positions"
)
department = models.ForeignKey(
Department, on_delete=models.CASCADE, related_name="positions"
)
title = models.CharField("Название должности", max_length=255)
date_from = models.DateField("Дата начала должности")
date_to = models.DateField("Дата окончания должности", null=True, blank=True)
history = HistoricalRecords()
class Meta:
verbose_name = "Должность сотрудника"
verbose_name_plural = "Должности сотрудников"
def clean(self):
super().clean()
if not self.date_from:
raise ValidationError("Дата начала обязательна")
@property
def is_active(self):
return not self.date_to or self.date_to >= timezone.now().date()
def __str__(self):
return f"{self.employee} — {self.title} ({self.department})"
class Absence(DateRangeMixin, models.Model):
ABSENCE_TYPE_CHOICES = [
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
("other", "Другое"),
]
STATUS_CHOICES = [
("pending", "На рассмотрении"),
("approved", "Утверждено"),
("rejected", "Отклонено"),
]
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name="absences"
)
type = models.CharField(
"Тип отсутствия", choices=ABSENCE_TYPE_CHOICES, max_length=32
)
date_from = models.DateField("Дата начала")
date_to = models.DateField("Дата окончания")
comment = models.TextField("Комментарий", blank=True)
status = models.CharField(
"Статус", choices=STATUS_CHOICES, max_length=16, default="pending"
)
class Meta:
verbose_name = "Отсутствие сотрудника"
verbose_name_plural = "Отсутствия сотрудников"
def clean(self):
super().clean()
if not self.date_from:
raise ValidationError("Дата начала обязательна")
if not self.date_to:
raise ValidationError("Дата окончания обязательна")
def __str__(self):
return f"{self.employee} — {self.get_type_display()} ({self.date_from}–{self.date_to})"
# TODO: Интегрировать с requests_app для создания заявок на absence
class Skill(models.Model):
name = models.CharField(max_length=100, unique=True)
class Meta:
verbose_name = "Навык"
verbose_name_plural = "Навыки"
def __str__(self):
return self.name
class Education(models.Model):
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name="educations"
)
institution = models.CharField(max_length=255)
degree = models.CharField(max_length=100)
graduation_year = models.IntegerField()
class Meta:
verbose_name = "Образование"
verbose_name_plural = "Образование"
def __str__(self):
return f"{self.institution} ({self.degree}, {self.graduation_year})"