Игорь Надеин 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

259 lines
8.3 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/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