226 lines
9.1 KiB
Python
226 lines
9.1 KiB
Python
# backend/requests_app/forms.py
|
||
import logging
|
||
|
||
from django import forms
|
||
from django.core.exceptions import ValidationError
|
||
|
||
from .constants import (
|
||
_ALLOWED_EXTS_DOTTED,
|
||
MAX_ATTACHMENT_SIZE,
|
||
SAFE_EXTENSIONS,
|
||
SAFE_MIME_TYPES,
|
||
)
|
||
from .models import Request
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _sniff_mime(uploaded_file) -> str | None:
|
||
"""
|
||
Осторожно определяем MIME по началу файла (python-magic).
|
||
Бросаем ValidationError, если не удаётся безопасно прочитать/вернуть позицию.
|
||
Возвращаем None, если magic недоступен.
|
||
"""
|
||
if not uploaded_file:
|
||
return None
|
||
|
||
try:
|
||
import magic # type: ignore
|
||
except Exception:
|
||
logger.warning(
|
||
"python-magic не установлен; строгая MIME-проверка отключена. "
|
||
"Будет использовано content_type/расширение."
|
||
)
|
||
return None
|
||
|
||
# Сохраняем позицию курсора
|
||
try:
|
||
pos = uploaded_file.file.tell()
|
||
except Exception:
|
||
logger.warning("Файл не поддерживает .tell(); проверка MIME невозможна.")
|
||
raise ValidationError("Не удалось проверить тип файла. Попробуйте другой файл.")
|
||
|
||
try:
|
||
head = uploaded_file.file.read(2048) or b""
|
||
if not head:
|
||
logger.warning("Не удалось прочитать начало файла для MIME-проверки.")
|
||
raise ValidationError(
|
||
"Не удалось проверить тип файла. Попробуйте другой файл."
|
||
)
|
||
mime = magic.Magic(mime=True).from_buffer(head)
|
||
return mime.split(";", 1)[0].strip() if mime else None
|
||
except ValidationError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning("Ошибка при MIME-проверке через python-magic: %s", e)
|
||
raise ValidationError("Не удалось проверить тип файла. Попробуйте другой файл.")
|
||
finally:
|
||
try:
|
||
uploaded_file.file.seek(pos)
|
||
except Exception:
|
||
logger.warning("Не удалось восстановить позицию файла после чтения.")
|
||
# Фейлим явно — чтобы не сохранить повреждённый поток
|
||
raise ValidationError(
|
||
"Не удалось проверить тип файла. Попробуйте другой файл."
|
||
)
|
||
|
||
|
||
class RequestForm(forms.ModelForm):
|
||
"""
|
||
Создание заявки пользователем.
|
||
UX-валидируем только *порядок дат* (без дублирования бизнес-логики модели).
|
||
"""
|
||
|
||
date_from = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}),
|
||
label="Дата начала",
|
||
help_text="Если не требуется — оставьте пустым.",
|
||
error_messages={"invalid": "Некорректная дата."},
|
||
)
|
||
date_to = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={"type": "date", "class": "form-control"}),
|
||
label="Дата окончания",
|
||
help_text="Если не требуется — оставьте пустым.",
|
||
error_messages={"invalid": "Некорректная дата."},
|
||
)
|
||
comment = forms.CharField(
|
||
required=False,
|
||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||
label="Комментарий",
|
||
)
|
||
attachment = forms.FileField(
|
||
required=False,
|
||
widget=forms.ClearableFileInput(
|
||
attrs={"class": "form-control", "accept": ".pdf,.jpg,.jpeg,.png"}
|
||
),
|
||
label="Вложение",
|
||
help_text="Допустимые форматы: PDF/JPG/PNG. До 10 МБ.",
|
||
error_messages={"invalid": "Недопустимое вложение."},
|
||
)
|
||
|
||
class Meta:
|
||
model = Request
|
||
fields = ["type", "date_from", "date_to", "comment", "attachment"]
|
||
widgets = {
|
||
"type": forms.Select(attrs={"class": "form-select"}),
|
||
}
|
||
labels = {"type": "Тип заявления"}
|
||
help_texts = {"type": "Выберите тип заявления."}
|
||
error_messages = {"type": {"required": "Пожалуйста, выберите тип заявления."}}
|
||
|
||
# UX: только порядок дат (модель проверит остальное)
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
start, end = cleaned.get("date_from"), cleaned.get("date_to")
|
||
if start and end and start > end:
|
||
self.add_error(
|
||
"date_to", "Дата окончания не может быть раньше даты начала."
|
||
)
|
||
return cleaned
|
||
|
||
def _validate_attachment_common(self, f):
|
||
if not f:
|
||
return
|
||
|
||
size = getattr(f, "size", 0)
|
||
if size == 0:
|
||
raise ValidationError("Пустой файл не допускается.")
|
||
if size and size > MAX_ATTACHMENT_SIZE:
|
||
raise ValidationError("Размер файла не должен превышать 10 МБ.")
|
||
|
||
name = getattr(f, "name", "") or ""
|
||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||
ext_dot = f".{ext}" if ext else ""
|
||
|
||
# Пытаемся определить MIME строго
|
||
try:
|
||
mime = _sniff_mime(f)
|
||
except ValidationError as e:
|
||
raise e
|
||
|
||
# Если magic недоступен (mime=None) — усиливаем проверку:
|
||
# требуется *и* корректный content_type, *и* корректное расширение.
|
||
content_type = (getattr(f, "content_type", None) or "").split(";", 1)[
|
||
0
|
||
].strip() or None
|
||
if mime is None:
|
||
if not (
|
||
content_type in SAFE_MIME_TYPES and ext_dot in _ALLOWED_EXTS_DOTTED
|
||
):
|
||
raise ValidationError(
|
||
"Недопустимый тип файла. Разрешены: PDF, JPG, PNG."
|
||
)
|
||
return
|
||
|
||
# Если MIME определён — он должен быть из белого списка
|
||
if mime not in SAFE_MIME_TYPES:
|
||
raise ValidationError("Недопустимый тип файла. Разрешены: PDF, JPG, PNG.")
|
||
# Дополнительная мягкая проверка на расширение
|
||
if ext_dot not in SAFE_EXTENSIONS:
|
||
raise ValidationError(
|
||
"Недопустимый тип файла (расширение). Разрешены: PDF, JPG, PNG."
|
||
)
|
||
|
||
def clean_attachment(self):
|
||
f = self.cleaned_data.get("attachment")
|
||
self._validate_attachment_common(f)
|
||
return f
|
||
|
||
|
||
class RequestStatusForm(forms.ModelForm):
|
||
"""
|
||
Обработка заявки HR/руководителем.
|
||
Обязательно передавайте user=..., чтобы ограничить допустимые статусы.
|
||
По умолчанию: approved/rejected; для staff/HR добавляется cancelled.
|
||
"""
|
||
|
||
BASE_ALLOWED = {
|
||
Request.STATUS_PENDING,
|
||
Request.STATUS_APPROVED,
|
||
Request.STATUS_REJECTED,
|
||
}
|
||
|
||
def __init__(self, *args, user, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
if user is None:
|
||
raise ValueError("RequestStatusForm требует параметр user.")
|
||
|
||
allowed = set(self.BASE_ALLOWED)
|
||
|
||
self._allowed_statuses = allowed
|
||
self.fields["status"].choices = [
|
||
(value, label)
|
||
for value, label in Request.STATUS_CHOICES
|
||
if value in allowed
|
||
]
|
||
|
||
class Meta:
|
||
model = Request
|
||
fields = ["status",]
|
||
widgets = {
|
||
"status": forms.Select(attrs={"class": "form-select"}),
|
||
}
|
||
labels = {
|
||
"status": "Статус заявления",
|
||
}
|
||
help_texts = {"status": "Выберите итог по заявлению."}
|
||
error_messages = {"status": {"required": "Укажите новый статус заявления."}}
|
||
|
||
def clean_status(self):
|
||
value = self.cleaned_data.get("status")
|
||
if value not in self._allowed_statuses:
|
||
raise ValidationError("Недопустимый статус для вашей роли.")
|
||
inst = self.instance
|
||
if getattr(inst, "is_final", False) and value != inst.status:
|
||
raise ValidationError(
|
||
"Финальный статус уже установлен и не может быть изменён."
|
||
)
|
||
return value
|
||
|
||
def clean_attachment(self):
|
||
f = self.cleaned_data.get("attachment")
|
||
RequestForm._validate_attachment_common(self, f)
|
||
return f
|