**Что сделано**
* **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`).
**Миграции**: нет.
259 lines
8.3 KiB
Python
259 lines
8.3 KiB
Python
# backend/employees/forms.py
|
||
from django import forms
|
||
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
|
||
from django.core.exceptions import ValidationError
|
||
from phonenumber_field.formfields import PhoneNumberField
|
||
|
||
from .models import Absence, Department, Education, Employee, EmployeeDepartment, Skill
|
||
|
||
|
||
# =========================
|
||
# Миксин для проверки аватара
|
||
# =========================
|
||
class AvatarValidationMixin:
|
||
def clean_avatar(self):
|
||
avatar = self.cleaned_data.get("avatar")
|
||
if avatar:
|
||
if avatar.size > 2 * 1024 * 1024:
|
||
raise forms.ValidationError("Размер файла не должен превышать 2MB")
|
||
if not avatar.name.lower().endswith((".jpg", ".jpeg", ".png")):
|
||
raise forms.ValidationError("Допустимые форматы: JPG, JPEG, PNG")
|
||
return avatar
|
||
|
||
|
||
# =========================
|
||
# Регистрация
|
||
# =========================
|
||
class RegistrationForm(AvatarValidationMixin, UserCreationForm):
|
||
first_name = forms.CharField(label="Имя", required=True)
|
||
last_name = forms.CharField(label="Фамилия", required=True)
|
||
email = forms.EmailField(label="Email", required=True)
|
||
avatar = forms.ImageField(label="Аватар", required=False)
|
||
birth_date = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(
|
||
attrs={
|
||
"type": "date",
|
||
"placeholder": "Дата рождения",
|
||
"class": "form-control",
|
||
}
|
||
),
|
||
)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = [
|
||
"phone_number",
|
||
"last_name",
|
||
"first_name",
|
||
"patronymic",
|
||
"birth_date",
|
||
"whatsapp",
|
||
"telegram",
|
||
"wechat",
|
||
"email",
|
||
"avatar",
|
||
"password1",
|
||
"password2",
|
||
]
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.fields["whatsapp"].required = False
|
||
self.fields["telegram"].required = False
|
||
self.fields["wechat"].required = False
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
# Более дружественная ошибка, чем из model.clean
|
||
if not (
|
||
cleaned.get("whatsapp") or cleaned.get("telegram") or cleaned.get("wechat")
|
||
):
|
||
raise ValidationError(
|
||
"Укажите хотя бы один контакт: WhatsApp, Telegram или WeChat."
|
||
)
|
||
return cleaned
|
||
|
||
|
||
# =========================
|
||
# Обновление профиля
|
||
# =========================
|
||
class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm):
|
||
avatar = forms.ImageField(
|
||
label="Аватар", required=False, widget=forms.ClearableFileInput()
|
||
)
|
||
telegram = forms.CharField(label="Telegram", required=False)
|
||
whatsapp = PhoneNumberField(label="WhatsApp", required=False)
|
||
wechat = PhoneNumberField(label="WeChat", required=False)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = (
|
||
"last_name",
|
||
"first_name",
|
||
"patronymic",
|
||
"birth_date",
|
||
"gender",
|
||
"email",
|
||
"phone_number",
|
||
"telegram",
|
||
"whatsapp",
|
||
"wechat",
|
||
"avatar",
|
||
)
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
# Дублируем проверку для понятного сообщения
|
||
if not (
|
||
cleaned.get("whatsapp") or cleaned.get("telegram") or cleaned.get("wechat")
|
||
):
|
||
raise ValidationError(
|
||
"Укажите хотя бы один контакт: WhatsApp, Telegram или WeChat."
|
||
)
|
||
return cleaned
|
||
|
||
|
||
# =========================
|
||
# Регистрация
|
||
# =========================
|
||
class RegistrationForm(AvatarValidationMixin, UserCreationForm):
|
||
first_name = forms.CharField(label="Имя", required=True)
|
||
last_name = forms.CharField(label="Фамилия", required=True)
|
||
email = forms.EmailField(label="Email", required=True)
|
||
avatar = forms.ImageField(label="Аватар", required=False)
|
||
birth_date = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(
|
||
attrs={
|
||
"type": "date",
|
||
"placeholder": "Дата рождения",
|
||
"class": "form-control",
|
||
}
|
||
),
|
||
)
|
||
|
||
class Meta:
|
||
model = Employee
|
||
fields = [
|
||
"phone_number",
|
||
"last_name",
|
||
"first_name",
|
||
"patronymic",
|
||
"birth_date",
|
||
"whatsapp",
|
||
"telegram",
|
||
"wechat",
|
||
"email",
|
||
"avatar",
|
||
"password1",
|
||
"password2",
|
||
]
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self.fields["whatsapp"].required = False
|
||
self.fields["telegram"].required = False
|
||
self.fields["wechat"].required = False
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
# Более дружественная ошибка, чем из model.clean
|
||
if not (
|
||
cleaned.get("whatsapp") or cleaned.get("telegram") or cleaned.get("wechat")
|
||
):
|
||
raise ValidationError(
|
||
"Укажите хотя бы один контакт: WhatsApp, Telegram или WeChat."
|
||
)
|
||
return cleaned
|
||
|
||
|
||
# =========================
|
||
# Отделы
|
||
# =========================
|
||
class DepartmentForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Department
|
||
fields = ["name", "description", "head"]
|
||
|
||
|
||
class InviteToDepartmentForm(forms.Form):
|
||
employee = forms.ModelChoiceField(
|
||
queryset=Employee.objects.none(),
|
||
label="Сотрудник",
|
||
widget=forms.Select(attrs={"class": "form-select"}),
|
||
)
|
||
|
||
def __init__(self, department, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
|
||
qs = (
|
||
Employee.objects.all()
|
||
# НЕ показываем тех, кто уже активен в отделе
|
||
.exclude(
|
||
departments_links__department=department,
|
||
departments_links__is_active=True,
|
||
)
|
||
# по желанию: не показывать руководителя, если он уже назначен
|
||
.exclude(id=department.head_id if department.head_id else None).order_by(
|
||
"last_name", "first_name"
|
||
)
|
||
)
|
||
|
||
self.fields["employee"].queryset = qs
|
||
|
||
|
||
class DepartmentMemberRoleForm(forms.ModelForm):
|
||
class Meta:
|
||
model = EmployeeDepartment
|
||
fields = ["role", "is_active"]
|
||
labels = {
|
||
"role": "Роль в отделе",
|
||
"is_active": "Активен в отделе",
|
||
}
|
||
widgets = {
|
||
"role": forms.TextInput(
|
||
attrs={
|
||
"class": "form-control",
|
||
"placeholder": "Например: Аналитик, Тимлид, Наставник",
|
||
"autocomplete": "off",
|
||
}
|
||
),
|
||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||
}
|
||
|
||
|
||
# =========================
|
||
# CRUD формы
|
||
# =========================
|
||
class AbsenceForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Absence
|
||
fields = ["type", "date_from", "date_to", "comment"]
|
||
|
||
def clean(self):
|
||
cleaned = super().clean()
|
||
start = cleaned.get("date_from")
|
||
end = cleaned.get("date_to")
|
||
if start and end and start > end:
|
||
raise ValidationError("Дата начала не может быть позже даты окончания.")
|
||
return cleaned
|
||
|
||
|
||
class SkillForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Skill
|
||
fields = ["name"]
|
||
|
||
|
||
class EducationForm(forms.ModelForm):
|
||
class Meta:
|
||
model = Education
|
||
fields = ["institution", "degree", "graduation_year"]
|
||
|
||
def clean_graduation_year(self):
|
||
year = self.cleaned_data.get("graduation_year")
|
||
if year and (year < 1900 or year > 2100):
|
||
raise ValidationError("Некорректный год окончания.")
|
||
return year
|