466 lines
18 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/requests_app/models.py
import logging
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
from django.db.models import F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .constants import (
SAFE_EXTENSIONS,
SAFE_MIME_TYPES,
)
from .enums import FINAL_STATUS, RequestStatus, RequestType, VacationFinanceStatus
logger = logging.getLogger(__name__)
def attachment_file_size(value):
"""Оставлено для совместимости со старыми миграциями."""
return None
def attachment_mime_validator(value):
"""
Доп. проверка MIME-типа. Если python-magic отсутствует — пишем WARNING и
пропускаем проверку (понижает безопасность).
"""
if not value:
return
try:
import magic # type: ignore
except ImportError:
logger.warning(
"python-magic не установлен, "
"проверка MIME-типов вложений отключена. "
"Это может снизить безопасность обработки файлов.",
extra={"event": "request.attachment.mime_validation_unavailable"},
)
return
# читаем «шапку» файла
pos = value.file.tell()
head = value.file.read(2048)
value.file.seek(pos)
if not head:
raise ValidationError(_("Недопустимый файл: пустое содержимое."))
mime = magic.Magic(mime=True).from_buffer(head)
if not mime or mime not in SAFE_MIME_TYPES:
raise ValidationError(
_("Недопустимый тип файла: %(mime)s."),
params={"mime": mime or "unknown"},
)
class RequestSettings(models.Model):
"""Настройки адресации заявлений сотрудников."""
name = models.CharField("Название", max_length=100, default="Основные настройки")
allowed_recipients = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="request_recipient_settings",
verbose_name="Доступные получатели",
help_text=(
"Если список пуст, заявление можно отправить любому активному "
"сотруднику. Заполните список, чтобы ограничить поле «Кому»."
),
)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Настройки заявлений"
verbose_name_plural = "Настройки заявлений"
def __str__(self):
return self.name
@classmethod
def get_solo(cls):
instance = cls.objects.first()
return instance if instance else cls.objects.create()
def get_allowed_recipients_queryset(self):
User = self.allowed_recipients.model
configured = self.allowed_recipients.visible_in_ui().filter(is_active=True)
if self.allowed_recipients.exists():
return configured
return User.objects.visible_in_ui().filter(is_active=True)
def recipients_are_restricted(self) -> bool:
return self.allowed_recipients.exists()
class Request(models.Model):
"""Заявление сотрудника: тип, даты, статус, согласование и вложение."""
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="requests",
verbose_name=_("Автор"),
)
approver = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="approved_requests",
null=True,
blank=True,
verbose_name=_("Согласующий"),
)
# Устаревшее поле, сохранено для обратной совместимости
department = models.ForeignKey(
"employees.Department",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Основной отдел (устар.)"),
help_text=_("Устаревшее поле, используйте departments"),
)
# Новые поля для множественных получателей
departments = models.ManyToManyField(
"employees.Department",
verbose_name=_("Отделы-получатели"),
blank=True,
related_name="received_requests",
help_text=_("Заявка будет видна всем уполномоченным сотрудникам этих отделов"),
)
recipients = models.ManyToManyField(
settings.AUTH_USER_MODEL,
verbose_name=_("Получатели"),
blank=True,
related_name="received_requests",
help_text=_("Сотрудники, которым адресована заявка"),
)
cc_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
verbose_name=_("Копия (CC)"),
blank=True,
related_name="requests_cc",
help_text=_("Сотрудники в копии (уведомления без обязанности рассмотрения)"),
)
sent_to_all_department = models.BooleanField(
_("Всем сотрудникам отделов"),
default=False,
help_text=_("Если включено, заявка видна всем в выбранных отделах"),
)
type = models.CharField(
_("Тип заявления"), choices=RequestType.choices, max_length=32
)
title = models.CharField(_("Тема"), max_length=200, blank=True)
date_from = models.DateField(_("Дата начала"), null=True, blank=True)
date_to = models.DateField(_("Дата окончания"), null=True, blank=True)
time_from = models.TimeField(_("Время начала"), null=True, blank=True)
time_to = models.TimeField(_("Время окончания"), null=True, blank=True)
comment = models.TextField(_("Комментарий"), blank=True)
status = models.CharField(
_("Статус"),
choices=RequestStatus.choices,
max_length=16,
default=RequestStatus.PENDING,
)
attachment = models.FileField(
_("Вложение"),
upload_to="requests/attachments/%Y/%m/",
blank=True,
null=True,
validators=[
FileExtensionValidator(allowed_extensions=SAFE_EXTENSIONS),
attachment_mime_validator,
],
)
created_at = models.DateTimeField(_("Создано"), auto_now_add=True)
updated_at = models.DateTimeField(_("Обновлено"), auto_now=True)
decided_at = models.DateTimeField(_("Решение принято"), null=True, blank=True)
class Meta:
permissions = [
("can_view_all_requests", "Можно просмотреть все заявления"),
("can_process_requests", "Может одобрять/отклонять заявления"),
]
verbose_name = _("Заявление")
verbose_name_plural = _("Заявления")
ordering = ["-created_at"]
# NOTE: проверь через EXPLAIN,
# не избыточны ли некоторые индексы для твоих
# кейсов
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["type", "created_at"]),
models.Index(fields=["employee", "created_at"]),
models.Index(
fields=["decided_at"],
name="req_decided_idx",
condition=Q(decided_at__isnull=False),
),
]
constraints = [
models.CheckConstraint(
name="request_date_range_valid",
condition=Q(date_to__isnull=True)
| Q(date_from__isnull=True)
| Q(date_from__lte=F("date_to")),
),
models.CheckConstraint(
name="request_short_day_off_interval_valid",
condition=Q(status=RequestStatus.DRAFT)
| ~Q(type=RequestType.SHORT_DAY_OFF)
| Q(
date_from__isnull=False,
date_to=F("date_from"),
time_from__isnull=False,
time_to__isnull=False,
time_to__gt=F("time_from"),
),
),
models.CheckConstraint(
name="request_approver_required_on_decision",
condition=Q(
status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED],
approver__isnull=False,
)
| Q(
status__in=[
RequestStatus.DRAFT,
RequestStatus.PENDING,
RequestStatus.CANCELLED,
],
approver__isnull=True,
),
),
]
def clean(self):
if self.type in {
RequestType.VACATION,
RequestType.SICK_LEAVE,
RequestType.DAY_OFF,
RequestType.MATERNITY,
} and not (self.date_from and self.date_to):
raise ValidationError(
_("Для выбранного типа укажите даты начала и окончания.")
)
if self.type == RequestType.SHORT_DAY_OFF:
if not (
self.date_from and self.date_to and self.time_from and self.time_to
):
raise ValidationError(
_("Для короткого отгула укажите дату и время начала и окончания.")
)
if self.date_from != self.date_to:
raise ValidationError(
_("Короткий отгул должен быть в пределах одного дня.")
)
if self.time_to <= self.time_from:
raise ValidationError(
_("Время окончания должно быть позже времени начала.")
)
if (
self.type in {RequestType.TRANSFER, RequestType.DISMISSAL}
and not self.date_from
):
raise ValidationError(_("Для перевода/увольнения укажите дату начала."))
if self.approver_id and self.approver_id == self.employee_id:
raise ValidationError(_("Согласующий не может совпадать с автором заявки."))
@property
def starts_at(self):
if not self.date_from:
return None
value = timezone.datetime.combine(
self.date_from,
self.time_from or timezone.datetime.min.time(),
)
return timezone.make_aware(value, timezone.get_current_timezone())
@property
def ends_at(self):
if not self.date_to:
return None
value = timezone.datetime.combine(
self.date_to,
self.time_to or timezone.datetime.max.time(),
)
return timezone.make_aware(value, timezone.get_current_timezone())
@property
def duration_minutes(self) -> int | None:
if not (self.time_from and self.time_to):
return None
start = timezone.datetime.combine(timezone.datetime.min.date(), self.time_from)
end = timezone.datetime.combine(timezone.datetime.min.date(), self.time_to)
return max(int((end - start).total_seconds() // 60), 0)
@property
def display_title(self) -> str:
"""Заголовок для списков: берём title или генерим на лету."""
return self.title or f"{self.get_type_display()} — {self.employee}"
@property
def is_final(self):
return self.status in FINAL_STATUS
@property
def all_recipients(self):
"""Все получатели: основные + CC"""
User = settings.AUTH_USER_MODEL
from django.apps import apps
UserModel = apps.get_model(User)
recipient_ids = set(self.recipients.values_list("id", flat=True))
cc_ids = set(self.cc_users.values_list("id", flat=True))
return UserModel.objects.filter(id__in=recipient_ids | cc_ids)
@property
def primary_recipients(self):
"""Только основные получатели (без CC)"""
return self.recipients.all()
def is_recipient(self, user):
"""Проверка, является ли пользователь получателем"""
# Прямой получатель или в копии
if self.recipients.filter(id=user.id).exists():
return True
if self.cc_users.filter(id=user.id).exists():
return True
# Если sent_to_all_department и пользователь в одном из отделов
if self.sent_to_all_department:
return self.departments.filter(
employeedepartment__employee=user,
employeedepartment__is_active=True,
).exists()
return False
def add_recipient(self, user, is_cc=False):
"""Добавить получателя"""
if is_cc:
self.cc_users.add(user)
else:
self.recipients.add(user)
def remove_recipient(self, user):
"""Удалить получателя из обеих групп"""
self.recipients.remove(user)
self.cc_users.remove(user)
def approve(self, by_user):
self.status = RequestStatus.APPROVED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def reject(self, by_user):
self.status = RequestStatus.REJECTED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def cancel(self):
self.status = RequestStatus.CANCELLED
if self.approver_id is not None:
self.approver = None
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def save(self, *args, **kwargs) -> None:
"""Синхронизация decided_at/approver с текущим статусом.
Raises:
ValueError: Если нарушена согласованность полей (редко).
"""
if self.status in FINAL_STATUS and not self.decided_at:
self.decided_at = timezone.now()
if (
self.status
in {
RequestStatus.DRAFT,
RequestStatus.PENDING,
RequestStatus.CANCELLED,
}
and self.approver_id
):
self.approver = None
super().save(*args, **kwargs)
def __str__(self):
return (
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
)
class RequestVacationTerms(models.Model):
"""Согласованные условия оплаты отпуска по заявлению."""
request = models.OneToOneField(
Request,
on_delete=models.CASCADE,
related_name="vacation_terms",
verbose_name="Заявление",
)
paid_days = models.PositiveSmallIntegerField(
"Оплачиваемые дни",
default=0,
)
finance_status = models.CharField(
"Передача в финансы",
max_length=16,
choices=VacationFinanceStatus.choices,
default=VacationFinanceStatus.NOT_DECIDED,
)
recorded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="recorded_vacation_terms",
verbose_name="Условия зафиксировал",
)
recorded_at = models.DateTimeField("Зафиксировано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
class Meta:
verbose_name = "Условия отпуска"
verbose_name_plural = "Условия отпусков"
@property
def total_days(self) -> int:
if not (self.request.date_from and self.request.date_to):
return 0
return max((self.request.date_to - self.request.date_from).days + 1, 0)
@property
def unpaid_days(self) -> int:
return max(self.total_days - self.paid_days, 0)
def clean(self):
errors = {}
if self.request.type != RequestType.VACATION:
errors["request"] = "Условия оплаты доступны только для отпуска."
if self.request.status != RequestStatus.APPROVED:
errors["request"] = "Сначала одобрите заявление на отпуск."
if self.paid_days > self.total_days:
errors["paid_days"] = (
"Оплачиваемых дней не может быть больше длительности отпуска."
)
if errors:
raise ValidationError(errors)
def __str__(self):
return f"Заявление #{self.request_id}: {self.paid_days} оплачиваемых дней"