226 lines
9.1 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/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