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`).

**Миграции**: нет.
This commit is contained in:
admin i 2025-08-20 17:49:38 +03:00
parent b401e86249
commit 5af1f9ec91
33 changed files with 1380 additions and 613 deletions

View File

@ -1,5 +1,9 @@
# backend\api\urls.py
from django.urls import path, include
urlpatterns = [
path('v1/', include('api.v1.urls')),
path(
'v1/', include(('api.v1.urls', "api_v1"), namespace="api_v1")
),
]

View File

@ -1,261 +1,15 @@
from rest_framework import serializers
from employees.models import Employee, Department, EmployeeAction, EmployeePosition, Absence, Skill, Education
class SkillSerializer(serializers.ModelSerializer):
class Meta:
model = Skill
fields = ['id', 'name']
class DepartmentSerializer(serializers.ModelSerializer):
head = serializers.StringRelatedField()
class Meta:
model = Department
fields = ['id', 'name', 'description', 'head', 'created_at']
class EmployeeActionSerializer(serializers.ModelSerializer):
employee = serializers.StringRelatedField(read_only=True)
class Meta:
model = EmployeeAction
fields = ['id', 'employee', 'action', 'date', 'comment', 'extra']
class EmployeePositionSerializer(serializers.ModelSerializer):
department = DepartmentSerializer(read_only=True)
employee = serializers.StringRelatedField(read_only=True)
class Meta:
model = EmployeePosition
fields = ['id', 'employee', 'department',
'title', 'date_from', 'date_to', 'is_active']
class AbsenceSerializer(serializers.ModelSerializer):
employee = serializers.StringRelatedField(read_only=True)
class Meta:
model = Absence
fields = ['id', 'employee', 'type',
'date_from', 'date_to', 'comment', 'status']
class EducationSerializer(serializers.ModelSerializer):
employee = serializers.StringRelatedField(read_only=True)
class Meta:
model = Education
fields = ['id', 'employee', 'institution', 'degree', 'graduation_year']
class EmployeeSerializer(serializers.ModelSerializer):
skills = SkillSerializer(many=True, read_only=True)
positions = EmployeePositionSerializer(many=True, read_only=True)
actions = EmployeeActionSerializer(many=True, read_only=True)
absences = AbsenceSerializer(many=True, read_only=True)
educations = EducationSerializer(many=True, read_only=True)
department = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = [
'id', 'last_name', 'first_name', 'patronymic', 'gender', 'avatar',
'phone_number', 'email', 'telegram', 'whatsapp', 'wechat',
'birth_date', 'skills', 'positions', 'actions', 'absences', 'educations',
'employment_status', 'is_actually_active', 'created_at', 'updated_at', 'department'
]
def get_department(self, obj):
active_position = obj.positions.filter(date_to__isnull=True).first()
if active_position:
return DepartmentSerializer(active_position.department).data
return None
def get_avatar(self, obj):
request = self.context.get('request')
if obj.avatar and request:
return request.build_absolute_uri(obj.avatar.url)
return None
# Календарь------------------------------------------------------------------------
# backend/api/serializers.py
import datetime as dt
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from rest_framework import serializers
from calendar_app.models import CompanyEvent, DepartmentEvent, Recurrence
class EmailSerializer(serializers.Serializer):
email = serializers.EmailField()
class EmailVerifySerializer(serializers.Serializer):
email = serializers.EmailField()
code = serializers.CharField(max_length=32)
def _aware(dt_or_date: dt.datetime | dt.date, to_utc: bool = False) -> dt.datetime:
"""
Делает datetime aware в текущем TZ (или переводит в UTC).
Если пришла date — берём локальную полночь.
"""
if isinstance(dt_or_date, dt.date) and not isinstance(dt_or_date, dt.datetime):
dtime = dt.datetime.combine(dt_or_date, dt.time.min)
else:
dtime = dt_or_date # type: ignore[assignment]
if timezone.is_naive(dtime):
dtime = timezone.make_aware(dtime, timezone.get_current_timezone())
if to_utc:
dtime = timezone.localtime(dtime, timezone.utc)
return dtime
def _as_iso(value, *, all_day: bool = True, to_utc: bool = False) -> str | None:
"""
Приводит value (date | datetime | str | None) к ISO-строке.
- Для all-day отдаём YYYY-MM-DD (без времени — так рекомендует FullCalendar).
- Для timed делаем aware datetime (локальный TZ по умолчанию или UTC).
"""
if value is None:
return None
# datetime
if isinstance(value, dt.datetime):
dtime = _aware(value, to_utc=to_utc)
return dtime.isoformat()
# date
if isinstance(value, dt.date):
if all_day:
return value.isoformat()
return _aware(value, to_utc=to_utc).isoformat()
# str
if isinstance(value, str):
dtime = parse_datetime(value)
if dtime:
return _aware(dtime, to_utc=to_utc).isoformat()
d = parse_date(value)
if d:
return d.isoformat() if all_day else _aware(d, to_utc=to_utc).isoformat()
return value # оставим как есть, чтобы не ронять API
return None
class CompanyEventSerializer(serializers.ModelSerializer):
start = serializers.SerializerMethodField()
end = serializers.SerializerMethodField()
allDay = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
recurrence_display = serializers.SerializerMethodField()
class Meta:
model = CompanyEvent
fields = (
"id",
"title",
"description",
"start",
"end",
"allDay",
"color",
"location",
"recurrence",
"recurrence_display",
"url",
)
read_only_fields = fields
def get_start(self, obj: CompanyEvent):
# Однодневное корпоративное событие
occ = getattr(obj, "occurrence_date", None)
return _as_iso(occ or obj.date, all_day=True)
def get_end(self, obj: CompanyEvent):
# Для all-day можно вернуть None (FullCalendar корректно отобразит один день)
return None
def get_allDay(self, obj: CompanyEvent):
return True
def get_url(self, obj: CompanyEvent):
# Если детальной страницы нет — можно вернуть календарь компании
try:
return obj.get_absolute_url()
except Exception:
return None
def get_recurrence_display(self, obj: CompanyEvent):
return obj.get_recurrence_display()
class DepartmentEventSerializer(serializers.ModelSerializer):
start = serializers.SerializerMethodField()
end = serializers.SerializerMethodField()
allDay = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()
recurrence_display = serializers.SerializerMethodField()
department_name = serializers.SerializerMethodField()
class Meta:
model = DepartmentEvent
fields = (
"id",
"title",
"description",
"start",
"end",
"allDay",
"color",
"location",
"recurrence",
"recurrence_display",
"department", # id отдела
"department_name",
"url",
)
read_only_fields = fields
def get_start(self, obj):
start = getattr(obj, "occ_start_date", obj.start_date)
return _as_iso(start, all_day=obj.all_day)
def get_end(self, obj):
end = getattr(obj, "occ_end_date", obj.end_date)
if not end:
return None
if obj.all_day:
# end-exclusive для all-day
return _as_iso(end + dt.timedelta(days=1), all_day=True)
return _as_iso(end, all_day=False)
def get_allDay(self, obj: DepartmentEvent):
return bool(obj.all_day)
def get_url(self, obj: DepartmentEvent):
try:
return obj.get_absolute_url()
except Exception:
return None
def get_recurrence_display(self, obj: DepartmentEvent):
return obj.get_recurrence_display()
def get_department_name(self, obj: DepartmentEvent):
# избежим N+1: в view делайте select_related("department")
dept = getattr(obj, "department", None)
return getattr(dept, "name", None)
class RegisterSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True, min_length=8)
first_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
last_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
phone = serializers.CharField(required=False, allow_blank=True, max_length=32)

View File

@ -1,128 +1,173 @@
from employees.models import (Absence, Department, Education, Employee,
EmployeeAction, EmployeePosition, Skill)
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from common.emails import send_templated_mail
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.models import Employee
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from ..permissions import IsDepartmentHeadOrAdmin, IsHR, IsSelfOrAdmin
from .serializers import (AbsenceSerializer, DepartmentSerializer,
EducationSerializer, EmployeeActionSerializer,
EmployeePositionSerializer, EmployeeSerializer,
SkillSerializer)
from .serializers import EmailSerializer, EmailVerifySerializer, RegisterSerializer
# опциональная нормализация телефона
try:
import phonenumbers
from phonenumbers import PhoneNumberFormat
except Exception:
phonenumbers = None
PhoneNumberFormat = None
class EmployeeViewSet(viewsets.ModelViewSet):
queryset = Employee.objects.all().prefetch_related("skills", "positions", "actions")
serializer_class = EmployeeSerializer
class ResendEmailAPIView(APIView):
"""
POST /api/v1/auth/resend-email/
body: {"email": "user@ex.com"}
"""
def get_permissions(self):
if self.action in [
"update",
"partial_update",
"destroy",
"set_avatar",
"add_skill",
]:
return [IsSelfOrAdmin()]
return [permissions.IsAuthenticated()]
throttle_scope = "anon"
@action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin])
def set_avatar(self, request, pk=None):
employee = self.get_object()
avatar = request.FILES.get("avatar")
if avatar:
employee.avatar = avatar
employee.save()
return Response({"status": "avatar set"})
return Response({"error": "No file"}, status=status.HTTP_400_BAD_REQUEST)
def post(self, request):
ser = EmailSerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
@action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin])
def add_skill(self, request, pk=None):
employee = self.get_object()
skill_name = request.data.get("skill")
if skill_name:
skill, _ = Skill.objects.get_or_create(name=skill_name)
employee.skills.add(skill)
return Response({"status": "skill added"})
return Response({"error": "No skill"}, status=status.HTTP_400_BAD_REQUEST)
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
@action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin])
def request_absence(self, request, pk=None):
return Response(
{"status": "Not implemented"}, status=status.HTTP_501_NOT_IMPLEMENTED
if user.email_verified:
return Response({"ok": False, "error": "already_verified"}, status=400)
# простая частотная защита по времени отправки в сессии оставим во вьюхе фронта,
# на API уровне можно дополнить throttle/ratelimit на IP
user.email_activation_code = get_random_string(6, "0123456789")
user.save(update_fields=["email_activation_code"])
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=200)
@action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin])
def request_transfer(self, request, pk=None):
return Response(
{"status": "Not implemented"}, status=status.HTTP_501_NOT_IMPLEMENTED
class VerifyEmailAPIView(APIView):
throttle_scope = "anon"
def post(self, request):
ser = EmailVerifySerializer(data=request.data)
ser.is_valid(raise_exception=True)
email = ser.validated_data["email"]
code = ser.validated_data["code"].strip()
user = Employee.objects.filter(email__iexact=email).first()
if not user:
return Response({"ok": False, "error": "user_not_found"}, status=404)
if not code:
return Response({"ok": False, "error": "empty_code"}, status=400)
if user.verify_email(code): # внутри должна ставиться is_active=True
return Response({"ok": True, "user_id": user.id}, status=200)
return Response({"ok": False, "error": "invalid_code"}, status=400)
def _normalize_phone(raw: str) -> str | None:
if not raw:
return None
if phonenumbers is None:
return raw.strip()
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
try:
pn = phonenumbers.parse(str(raw), region)
if not phonenumbers.is_valid_number(pn):
return None
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
except Exception:
return None
def _detect_phone_field() -> str | None:
for n in ("phone", "phone_number", "mobile", "msisdn", "tel"):
if any(f.name == n for f in Employee._meta.fields):
return n
return None
PHONE_FIELD = _detect_phone_field()
class RegisterAPIView(APIView):
"""
POST /api/v1/auth/register/
body: {email, password, first_name?, last_name?, phone?}
Поведение:
- если email уже подтверждён → 400 email_taken
- если пользователь есть, но не подтверждён → перегенерируем код и шлём письмо (ok + resent=True)
- если нет пользователя → создаём, is_active=False, отправляем код (ok)
"""
throttle_scope = "anon"
@transaction.atomic
def post(self, request):
ser = RegisterSerializer(data=request.data)
ser.is_valid(raise_exception=True)
v = ser.validated_data
email = v["email"].strip().lower()
password = v["password"]
user = Employee.objects.filter(email__iexact=email).first()
if user and user.email_verified:
return Response({"ok": False, "error": "email_taken"}, status=400)
if user and not user.email_verified:
user.email_activation_code = get_random_string(6, "0123456789")
user.is_active = False
user.set_password(
password
) # обновим пароль, если вдруг был пустой/тестовый
if PHONE_FIELD and v.get("phone"):
norm = _normalize_phone(v["phone"])
if norm:
setattr(user, PHONE_FIELD, norm)
if v.get("first_name"):
user.first_name = v["first_name"]
if v.get("last_name"):
user.last_name = v["last_name"]
user.save()
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True, "resent": True}, status=200)
# создаём нового
user = Employee(
email=email,
first_name=v.get("first_name", ""),
last_name=v.get("last_name", ""),
email_verified=False,
is_active=False,
email_activation_code=get_random_string(6, "0123456789"),
)
if PHONE_FIELD and v.get("phone"):
norm = _normalize_phone(v["phone"])
if norm:
setattr(user, PHONE_FIELD, norm)
user.set_password(password)
user.save()
class DepartmentViewSet(viewsets.ModelViewSet):
queryset = Department.objects.all()
serializer_class = DepartmentSerializer
permission_classes = [IsDepartmentHeadOrAdmin]
def get_queryset(self):
user = self.request.user
if user.is_staff or user.is_superuser:
return Department.objects.all()
return Department.objects.filter(head=user)
class EmployeeActionViewSet(viewsets.ModelViewSet):
queryset = EmployeeAction.objects.all()
serializer_class = EmployeeActionSerializer
permission_classes = [IsHR | IsDepartmentHeadOrAdmin]
class EmployeePositionViewSet(viewsets.ModelViewSet):
queryset = EmployeePosition.objects.all()
serializer_class = EmployeePositionSerializer
permission_classes = [IsHR | IsDepartmentHeadOrAdmin]
class AbsenceViewSet(viewsets.ModelViewSet):
serializer_class = AbsenceSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
if user.is_staff or user.is_superuser:
return Absence.objects.all()
return Absence.objects.filter(employee=user)
def perform_create(self, serializer):
serializer.save(employee=self.request.user)
def perform_update(self, serializer):
if (
serializer.instance.employee != self.request.user
and not self.request.user.is_staff
):
raise PermissionDenied("Вы не можете редактировать чужие заявления.")
serializer.save()
def perform_destroy(self, instance):
if instance.employee != self.request.user and not self.request.user.is_staff:
raise PermissionDenied("Вы не можете удалить чужое заявление.")
instance.delete()
class SkillViewSet(viewsets.ModelViewSet):
queryset = Skill.objects.all()
serializer_class = SkillSerializer
permission_classes = [permissions.IsAuthenticated]
class EducationViewSet(viewsets.ModelViewSet):
serializer_class = EducationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Education.objects.all()
return Education.objects.filter(employee=user)
send_templated_mail(
subject="Подтверждение регистрации",
to=[user.email],
template_base="emails/registration_verify_code",
context={"code": user.email_activation_code, "user": user},
)
return Response({"ok": True}, status=201)

View File

@ -2,28 +2,20 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet
from .employees.views import (
AbsenceViewSet,
DepartmentViewSet,
EducationViewSet,
EmployeeActionViewSet,
EmployeePositionViewSet,
EmployeeViewSet,
SkillViewSet,
)
from .employees.views import (RegisterAPIView, ResendEmailAPIView,
VerifyEmailAPIView)
router = DefaultRouter()
router.register(r"employees", EmployeeViewSet, basename="employee")
router.register(r"departments", DepartmentViewSet, basename="department")
router.register(r"actions", EmployeeActionViewSet, basename="action")
router.register(r"positions", EmployeePositionViewSet, basename="position")
router.register(r"absences", AbsenceViewSet, basename="absence")
router.register(r"skills", SkillViewSet, basename="skill")
router.register(r"educations", EducationViewSet, basename="education")
router.register(
r"calendar/company-events", CompanyEventsViewSet, basename="company-events"
)
app_name = "api_v1"
urlpatterns = [
path("auth/register/", RegisterAPIView.as_view(), name="register"),
path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
path(
"calendar/departments/<int:pk>/events/",
DepartmentEventsViewSet.as_view({"get": "list"}),

View File

44
backend/common/emails.py Normal file
View File

@ -0,0 +1,44 @@
# backend/common/emails.py
from pathlib import Path
from typing import Iterable, Mapping, Optional
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
def send_templated_mail(
subject: str,
to: Iterable[str],
template_base: str,
context: Mapping,
from_email: Optional[str] = None,
attachments: Optional[list[tuple[str, bytes, str]]] = None,
) -> int:
"""
Отправляет письмо в HTML + plain текстах по шаблонам:
templates/<template_base>.html
templates/<template_base>.txt (опционально)
attachments: список троек (name, content_bytes, mimetype)
"""
html_body = render_to_string(f"{template_base}.html", context)
# .txt файл опционален — если его нет, берём strip_tags от html
try:
text_body = render_to_string(f"{template_base}.txt", context)
except Exception:
text_body = strip_tags(html_body)
email = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
to=list(to),
)
email.attach_alternative(html_body, "text/html")
if attachments:
for name, data, mimetype in attachments:
email.attach(name, data, mimetype)
return email.send(fail_silently=False)

View File

@ -0,0 +1,61 @@
# employees/api_client.py
from django.urls import reverse
from django.http import HttpRequest
import requests
class APIError(Exception):
pass
def _json_safe(x):
try:
from phonenumber_field.phonenumber import PhoneNumber
except Exception:
PhoneNumber = None
if isinstance(x, dict):
return {k: _json_safe(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [_json_safe(v) for v in x]
if PhoneNumber and isinstance(x, PhoneNumber):
return x.as_e164 or x.raw_input or str(x)
return x
def _abs_url(request: HttpRequest, url_name: str, **kwargs) -> str:
rel = reverse(url_name, kwargs=kwargs or None)
return request.build_absolute_uri(rel)
def api_post(request: HttpRequest, url_name: str, payload: dict, **kwargs):
url = _abs_url(request, url_name)
payload = _json_safe(payload or {})
headers = {"Accept": "application/json"}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=10, **kwargs)
except requests.RequestException as e:
raise APIError(f"api_network_error: {e}")
try:
data = resp.json()
except ValueError:
# ← Вот тут будем видеть «почему»
snippet = (resp.text or "")[:500]
raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}")
return resp.status_code, data
def api_get(request: HttpRequest, url_name: str, params: dict | None = None, **kwargs):
url = _abs_url(request, url_name)
headers = {"Accept": "application/json"}
try:
resp = requests.get(
url, params=params or {}, headers=headers, timeout=10, **kwargs
)
except requests.RequestException as e:
raise APIError(f"api_network_error: {e}")
try:
data = resp.json()
except ValueError:
snippet = (resp.text or "")[:500]
raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}")
return resp.status_code, data

View File

@ -4,8 +4,7 @@ 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)
from .models import Absence, Department, Education, Employee, EmployeeDepartment, Skill
# =========================
@ -30,6 +29,16 @@ class RegistrationForm(AvatarValidationMixin, UserCreationForm):
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
@ -106,10 +115,57 @@ class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm):
# =========================
# SMS верификация
# Регистрация
# =========================
class SMSCodeVerifyForm(forms.Form):
code = forms.CharField(label="Код из SMS", max_length=6)
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
# =========================

View File

View File

@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from common.emails import send_templated_mail
class Command(BaseCommand):
help = "Отправить тестовое письмо на указанный адрес"
def add_arguments(self, parser):
parser.add_argument("email", type=str)
def handle(self, *args, **opts):
to_email = opts["email"]
try:
sent = send_templated_mail(
subject="Тестовое письмо",
to=[to_email],
template_base="emails/registration_verify_code",
context={"code": "123456", "user": None},
)
self.stdout.write(self.style.SUCCESS(f"Письмо отправлено ({sent}). Бэкенд: {settings.EMAIL_BACKEND}"))
except Exception as e:
raise CommandError(f"Ошибка отправки: {e}")

View File

@ -0,0 +1,42 @@
# Generated by Django 5.2.4 on 2025-08-20 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0010_historicalemployeeaction_historicalemployeeposition'),
]
operations = [
migrations.AddField(
model_name='department',
name='head_appointed_at',
field=models.DateTimeField(auto_now=True, verbose_name='Дата назначения руководителя'),
),
migrations.AddField(
model_name='employee',
name='email_activation_code',
field=models.CharField(blank=True, max_length=6, null=True),
),
migrations.AddField(
model_name='employee',
name='email_verified',
field=models.BooleanField(default=False, verbose_name='Email подтверждён'),
),
migrations.AlterField(
model_name='employeeaction',
name='action',
field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
),
migrations.AlterField(
model_name='historicalemployeeaction',
name='action',
field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'),
),
migrations.AlterUniqueTogether(
name='employeedepartment',
unique_together={('employee', 'department')},
),
]

View File

@ -1,37 +1,89 @@
# backend\employees\models.py
from django.conf import settings
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models
from django.utils import timezone
from django.utils.crypto import get_random_string
from phonenumber_field.modelfields import PhoneNumberField
from simple_history.models import HistoricalRecords
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES
class DateRangeMixin(models.Model):
class Meta:
abstract = True
def clean(self):
super().clean()
if hasattr(self, "date_from") and hasattr(self, "date_to"):
if self.date_from and self.date_to and self.date_from >= self.date_to:
raise ValidationError("Дата начала должна быть раньше даты окончания")
class EmployeeManager(BaseUserManager):
use_in_migrations = True
def create_user(self, phone_number, email, password=None, **extra_fields):
def create_user(self, credential, password=None, **extra_fields):
# Определяем, email ли это или phone
if "@" in credential:
email = self.normalize_email(credential)
phone_number = extra_fields.pop("phone_number", None)
else:
phone_number = credential
email = extra_fields.pop("email", None)
if not email:
raise ValueError("Пользователь должен иметь email для подтверждения")
if not phone_number:
raise ValueError("Пользователь должен иметь номер телефона")
if not email:
raise ValueError("Пользователь должен иметь email")
email = self.normalize_email(email)
user = self.model(phone_number=phone_number, email=email, **extra_fields)
user = self.model(email=email, phone_number=phone_number, **extra_fields)
user.set_password(password)
user.email_verified = False # Требует подтверждения
user.email_activation_code = get_random_string(
6, allowed_chars="0123456789"
) # Криптостойкий код
user.save(using=self._db)
# Отправка confirmation email (в будущем асинхронно через bots/celery)
send_mail(
"Подтверждение регистрации",
f"Ваш код подтверждения: {user.email_activation_code}",
settings.DEFAULT_FROM_EMAIL,
[user.email],
fail_silently=False,
)
return user
def create_superuser(self, phone_number, email, password=None, **extra_fields):
def create_superuser(self, credential, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("email_verified", True) # Суперюзер confirmed
if not extra_fields.get("is_staff"):
raise ValueError("Суперпользователь должен иметь is_staff=True")
if not extra_fields.get("is_superuser"):
raise ValueError("Суперпользователь должен иметь is_superuser=True")
return self.create_user(phone_number, email, password, **extra_fields)
return self.create_user(credential, password, **extra_fields)
def get_active(self):
"""
Возвращает list активных сотрудников, оптимизированный: предварительная фильтрация в DB по полям,
затем Python-фильтр для сложной логики (actions).
"""
qs = (
self.get_queryset()
.filter(email_verified=True, is_active=True)
.prefetch_related("actions")
.order_by("-id")
)
return [
e for e in qs if e.is_actually_active
] # Финальный фильтр по actions в Python
class Employee(AbstractUser):
@ -52,12 +104,16 @@ class Employee(AbstractUser):
updated_at = models.DateTimeField(auto_now=True)
skills = models.ManyToManyField("Skill", related_name="employees", blank=True)
is_active = models.BooleanField(default=False)
sms_activation_code = models.CharField(max_length=6, blank=True, null=True)
sms_activation_code = models.CharField(
max_length=6, blank=True, null=True
) # Для будущей SMS-активации в bots (fallback к email)
email_verified = models.BooleanField("Email подтверждён", default=False)
email_activation_code = models.CharField(max_length=6, blank=True, null=True)
objects = EmployeeManager()
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = ["first_name", "last_name", "email"]
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["first_name", "last_name", "phone_number"]
class Meta:
verbose_name = "Сотрудник"
@ -80,11 +136,6 @@ class Employee(AbstractUser):
def __str__(self):
return f"{self.last_name} {self.first_name}"
@classmethod
def get_active(cls):
qs = cls.objects.prefetch_related("actions").order_by("-created_at")
return [e for e in qs if e.is_actually_active]
@property
def employment_status(self):
last_action = self.actions.order_by("-date").first()
@ -94,8 +145,6 @@ class Employee(AbstractUser):
@property
def departments(self):
from employees.models import Department, EmployeeDepartment
# Отделы, где сотрудник активен
department_ids = EmployeeDepartment.objects.filter(
employee=self, is_active=True
@ -108,18 +157,30 @@ class Employee(AbstractUser):
@property
def is_actually_active(self):
if not self.email_verified:
return False # Неактивен без подтверждения email
last_action = self.actions.order_by("-date").first()
if not last_action:
return False
return self.is_active
return last_action.action != ACTION_DISMISSED
def verify_email(self, code):
if self.email_activation_code == code:
self.email_verified = True
self.email_activation_code = None
self.is_active = True # Активируем после подтверждения
self.save()
return True
return False
class EmployeeAction(models.Model):
employee = models.ForeignKey(
Employee, related_name="actions", on_delete=models.CASCADE
)
action = models.CharField("Кадровое событие", max_length=32, choices=ACTION_CHOICES)
action = models.CharField(
"Кадровое событие", max_length=50, choices=ACTION_CHOICES
) # Увеличено для расширения
date = models.DateTimeField("Дата действия")
comment = models.TextField("Комментарий/причина", blank=True)
extra = models.JSONField("Дополнительно", blank=True, null=True)
@ -145,6 +206,9 @@ class Department(models.Model):
null=True,
blank=True,
)
head_appointed_at = models.DateTimeField(
"Дата назначения руководителя", auto_now=True
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@ -158,12 +222,12 @@ class Department(models.Model):
def active_employees(self):
"""
Все активные сотрудники отдела (через EmployeeDepartment),
включая руководителя, если он есть.
включая руководителя, если он есть и активен.
"""
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
employees = [ed.employee for ed in qs]
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
if self.head and self.head not in employees:
if self.head and self.head.is_actually_active and self.head not in employees:
employees.append(self.head)
return employees
@ -178,18 +242,21 @@ class Department(models.Model):
is_active=True, date_from__gte=month_ago
).select_related("employee")
employees = [ed.employee for ed in qs]
employees = [ed.employee for ed in qs if ed.employee.is_actually_active]
# Добавляем руководителя, если он недавно назначен
if self.head:
head_joined = self.created_at >= month_ago
if head_joined and self.head not in employees:
employees.append(self.head)
# Добавляем руководителя, если он недавно назначен и активен
if (
self.head
and self.head_appointed_at >= month_ago
and self.head.is_actually_active
and self.head not in employees
):
employees.append(self.head)
return employees
class EmployeeDepartment(models.Model):
class EmployeeDepartment(DateRangeMixin, models.Model):
employee = models.ForeignKey(
"Employee", on_delete=models.CASCADE, related_name="departments_links"
)
@ -202,7 +269,7 @@ class EmployeeDepartment(models.Model):
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ("employee", "department", "date_from")
unique_together = ("employee", "department")
verbose_name = "Принадлежность к отделу"
verbose_name_plural = "Принадлежности к отделам"
@ -210,7 +277,7 @@ class EmployeeDepartment(models.Model):
return f"{self.employee} в {self.department} ({self.role or 'сотрудник'})"
class EmployeePosition(models.Model):
class EmployeePosition(DateRangeMixin, models.Model):
"""
Опциональная модель для хранения названия должности.
Не влияет на определение, в каком отделе сотрудник работает.
@ -231,6 +298,11 @@ class EmployeePosition(models.Model):
verbose_name = "Должность сотрудника"
verbose_name_plural = "Должности сотрудников"
def clean(self):
super().clean()
if not self.date_from:
raise ValidationError("Дата начала обязательна")
@property
def is_active(self):
return not self.date_to or self.date_to >= timezone.now().date()
@ -239,7 +311,7 @@ class EmployeePosition(models.Model):
return f"{self.employee} — {self.title} ({self.department})"
class Absence(models.Model):
class Absence(DateRangeMixin, models.Model):
ABSENCE_TYPE_CHOICES = [
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
@ -269,9 +341,18 @@ class Absence(models.Model):
verbose_name = "Отсутствие сотрудника"
verbose_name_plural = "Отсутствия сотрудников"
def clean(self):
super().clean()
if not self.date_from:
raise ValidationError("Дата начала обязательна")
if not self.date_to:
raise ValidationError("Дата окончания обязательна")
def __str__(self):
return f"{self.employee} — {self.get_type_display()} ({self.date_from}–{self.date_to})"
# TODO: Интегрировать с requests_app для создания заявок на absence
class Skill(models.Model):
name = models.CharField(max_length=100, unique=True)

View File

@ -7,8 +7,10 @@ app_name = "employees"
urlpatterns = [
# --- Регистрация и активация ---
path("register/", views.RegisterView.as_view(), name="register"),
path("sms-verify/", views.SMSVerifyView.as_view(), name="sms_verify"),
path("resend-sms/", views.resend_sms, name="resend_sms"),
# path("sms-verify/", views.SMSVerifyView.as_view(), name="sms_verify"),
path("email-verify/", views.EmailVerifyView.as_view(), name="email_verify"),
# path("resend-sms/", views.resend_sms, name="resend_sms"),
path("resend-email/", views.resend_email, name="resend_email"),
# --- Профиль и сотрудники ---
path("profile/", views.profile, name="profile"),

View File

@ -1,13 +1,10 @@
# backend/employees/views_front.py
import os
import random
from datetime import timedelta
import requests
from communications.models import Chat
from django.contrib import messages
from common.emails import send_templated_mail
from django.contrib.auth import login
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.db.models import (
@ -27,12 +24,17 @@ from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.views import View
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
from dotenv import load_dotenv
from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
ListView,
UpdateView,
)
from feed.constants import TYPE_DEPARTMENT
from feed.models import Comment, Post, PostLike
from phonenumber_field.phonenumber import to_python
from .forms import (
AbsenceForm,
@ -43,119 +45,228 @@ from .forms import (
ProfileUpdateForm,
RegistrationForm,
SkillForm,
SMSCodeVerifyForm,
)
from .models import (
Absence,
Department,
Education,
Employee,
EmployeeAction, # ← добавлено
EmployeeAction,
EmployeeDepartment,
Skill,
)
load_dotenv()
# =========================
# Утилиты
# =========================
def send_sms_alpha(phone: str, text: str, sender: str = "EUSRR"):
api_key = os.getenv("SMS_API_TOKEN", "")
api_url = os.getenv("SMS_API_URL", "https://new.smsgorod.ru/apiSms/create")
payload = {
"apiKey": api_key,
"sms": [{"channel": "char", "sender": sender, "text": text, "phone": phone}],
}
try:
resp = requests.post(
api_url,
json=payload,
headers={"Content-Type": "application/json"},
timeout=5,
)
return resp.json()
except Exception as e:
return {"error": str(e)}
def _annotated_feed(base_qs, user):
"""
Добавляет к queryset постов:
- is_liked: bool — лайкал ли текущий пользователь
- comments_count: int — количество комментариев
- last_comment_list: [Comment] длиной 1 с автором (последний)
Плюс select_related(author, department) и сортировка как в ленте.
"""
is_liked = (
Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user))
if user.is_authenticated
else Value(False, output_field=BooleanField())
)
last_comment_qs = Comment.objects.select_related("author").order_by("-created_at")[
:1
]
return (
base_qs.select_related("author", "department")
.annotate(is_liked=is_liked, comments_count=Count("comments"))
.prefetch_related(
Prefetch("comments", queryset=last_comment_qs, to_attr="last_comment_list")
)
.order_by("-pinned", "-created_at")
)
from django.http import HttpResponseNotAllowed
from employees.api_client import api_post, APIError
# =========================
# Регистрация
# =========================
class RegisterView(CreateView):
model = Employee
form_class = RegistrationForm
template_name = "registration/register.html"
success_url = reverse_lazy("feed:feed_list")
def form_valid(self, form):
user = form.save(commit=False)
user.is_active = True
user.save()
login(self.request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect("feed:feed_list")
cd = form.cleaned_data
email = cd.get("email")
password = cd.get("password1") or cd.get("password") or ""
first_name = cd.get("first_name", "")
last_name = cd.get("last_name", "")
phone = cd.get("phone") or cd.get("phone_number") or ""
# >>> добавь это приведение типов <<<
try:
# если установлен django-phonenumber-field
from phonenumber_field.phonenumber import PhoneNumber
except Exception:
PhoneNumber = None
if PhoneNumber and isinstance(phone, PhoneNumber):
# canonical формат для API
phone = phone.as_e164 or phone.raw_input or str(phone)
elif phone is None:
phone = ""
else:
phone = str(phone).strip()
payload = {
"email": email,
"password": password,
"first_name": first_name,
"last_name": last_name,
}
if phone:
payload["phone"] = phone
try:
status_code, data = api_post(self.request, "api_v1:register", payload)
except APIError as e:
form.add_error(None, f"Ошибка регистрации: {e}")
return self.form_invalid(form)
if status_code in (200, 201) and data.get("ok"):
self.request.session["email"] = email
if data.get("resent"):
messages.info(self.request, "Пользователь уже был создан. Мы отправили код повторно.")
else:
messages.success(self.request, "Код подтверждения отправлен на вашу почту.")
return redirect("employees:email_verify")
err = (data or {}).get("error")
if err == "email_taken":
form.add_error("email", "Этот email уже используется.")
else:
form.add_error(None, "Не удалось зарегистрировать. Попробуйте позже.")
return self.form_invalid(form)
class SMSVerifyView(View):
template_name = "registration/sms_verify.html"
class EmailVerifyView(View):
template_name = "registration/email_verify.html"
def get(self, request):
return render(
request, self.template_name, {"phone": request.session.get("phone_number")}
request, self.template_name, {"email": request.session.get("email")}
)
def post(self, request):
phone = request.session.get("phone_number")
if not phone:
email = request.session.get("email")
if not email:
messages.error(request, "Сессия истекла. Пройдите регистрацию заново.")
return redirect("employees:register")
user = Employee.objects.filter(phone_number=to_python(phone)).first()
if not user:
code = (request.POST.get("code") or "").strip()
if not code:
messages.error(request, "Введите код подтверждения.")
return render(request, self.template_name, {"email": email})
# ✨ зовём API
try:
status_code, data = api_post(
request, "api_v1:verify-email", {"email": email, "code": code}
)
except APIError as e:
messages.error(request, f"Ошибка проверки кода: {e}")
return render(request, self.template_name, {"email": email})
if status_code == 200 and data.get("ok"):
# Автовход
user = None
user_id = data.get("user_id")
if user_id:
user = Employee.objects.filter(id=user_id).first()
if not user:
user = Employee.objects.filter(email__iexact=email).first()
if user:
request.session.pop("email", None)
login(
request, user, backend="django.contrib.auth.backends.ModelBackend"
)
messages.success(request, "Email подтверждён! Добро пожаловать.")
return redirect("feed:feed_list")
messages.warning(
request,
"Email подтверждён, но не удалось выполнить вход. Войдите вручную.",
)
return redirect("login")
# Обработка ошибок API
err = (data or {}).get("error")
if err == "user_not_found":
messages.error(request, "Пользователь не найден.")
return redirect("employees:register")
form = SMSCodeVerifyForm(request.POST)
if form.is_valid():
if str(user.sms_activation_code) == str(form.cleaned_data["code"]):
user.is_active = True
user.sms_activation_code = None
user.save()
messages.success(request, "Телефон подтверждён! Можете войти.")
return redirect("login")
if err == "invalid_code":
messages.error(request, "Код неверный.")
return render(request, self.template_name, {"form": form, "phone": phone})
return render(request, self.template_name, {"email": email})
if err == "empty_code":
messages.error(request, "Введите код подтверждения.")
return render(request, self.template_name, {"email": email})
messages.error(request, "Не удалось подтвердить email. Попробуйте позже.")
return render(request, self.template_name, {"email": email})
def resend_sms(request):
return HttpResponseNotAllowed(["GET", "POST"], "Not Implemented")
def resend_email(request):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
email = request.session.get("email")
if not email:
messages.error(request, "Сессия истекла.")
return redirect("employees:register")
# Простая защита от частых повторов: не чаще 1 раза в минуту
# Локальная защита от частых повторов оставляем (по сессии)
now_ts = int(timezone.now().timestamp())
last_ts = request.session.get("email_code_last_sent_ts")
if last_ts and (now_ts - int(last_ts) < 60):
messages.error(request, "Слишком часто. Попробуйте через минуту.")
return redirect("employees:email_verify")
# Вызываем API
try:
status_code, data = api_post(
request,
"api_v1:resend-email",
{"email": email},
)
except APIError as e:
messages.error(request, f"Ошибка отправки кода: {e}")
return redirect("employees:email_verify")
if status_code == 200 and data.get("ok"):
request.session["email_code_last_sent_ts"] = now_ts
messages.success(request, "Код отправлен повторно.")
return redirect("employees:email_verify")
# Обработка ошибок API
err = (data or {}).get("error")
if err == "user_not_found":
messages.error(request, "Пользователь не найден.")
return redirect("employees:register")
if err == "already_verified":
messages.error(request, "Email уже подтверждён.")
return redirect("login")
messages.error(request, "Не удалось отправить код. Попробуйте позже.")
return redirect("employees:email_verify")
# =========================
# Утилиты
# =========================
def _annotated_feed(base_qs, user):
"""
Добавляет к queryset постов:
- is_liked: bool — лайкал ли текущий пользователь
- comments_count: int — количество комментариев
- comments_sorted: [Comment] — все комментарии поста, отсортированные по -created_at
Плюс select_related(author, department) и сортировка как в ленте.
В шаблоне используйте первый элемент:
{{ post.comments_sorted|first }}
"""
is_liked = (
Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user))
if getattr(user, "is_authenticated", False)
else Value(False, output_field=BooleanField())
)
last_comment_qs = Comment.objects.select_related("author").order_by("-created_at")
return (
base_qs.select_related("author", "department")
.annotate(is_liked=is_liked, comments_count=Count("comments"))
.prefetch_related(
Prefetch(
"comments",
queryset=last_comment_qs,
to_attr="comments_sorted",
)
)
.order_by("-pinned", "-created_at")
)
# =========================
@ -168,12 +279,11 @@ def profile(request):
show_edit = False
# кадровые события — видит только тот, у кого есть право
hr_history = (
EmployeeAction.history
.filter(employee_id=employee.id)
.select_related("history_user")
.order_by("-history_date")
)
hr_actions = []
if request.user.has_perm("employees.view_employeeaction"):
hr_actions = EmployeeAction.objects.filter(employee=employee).order_by("-date")[
:50
]
emp_departments = employee.departments.order_by("name")
if request.method == "POST":
@ -195,7 +305,7 @@ def profile(request):
"own": True,
"posts": posts,
"show_edit": show_edit,
"hr_actions": hr_history,
"hr_actions": hr_actions,
"emp_departments": emp_departments,
},
)
@ -210,12 +320,11 @@ def employee_detail(request, pk):
posts = _annotated_feed(Post.objects.filter(author=employee), request.user)[:10]
# кадровые события целевого сотрудника — только при наличии права
hr_history = (
EmployeeAction.history
.filter(employee_id=employee.id)
.select_related("history_user")
.order_by("-history_date")
)
hr_actions = []
if request.user.has_perm("employees.view_employeeaction"):
hr_actions = EmployeeAction.objects.filter(employee=employee).order_by("-date")[
:50
]
emp_departments = employee.departments.order_by("name")
return render(
request,
@ -225,7 +334,7 @@ def employee_detail(request, pk):
"own": False,
"posts": posts,
"show_edit": False,
"hr_actions": hr_history, # ← добавлено
"hr_actions": hr_actions,
"emp_departments": emp_departments,
},
)
@ -258,6 +367,10 @@ class EmployeeListView(LoginRequiredMixin, ListView):
# Текущий отдел (для head_id)
department = Department.objects.filter(pk=dept_id).only("id", "head_id").first()
# Если отдел не найден, логично вернуть пустой список
if not department:
return qs.none()
# Активная связь сотрудника с отделом (для аннотаций)
link_qs = EmployeeDepartment.objects.filter(
department_id=dept_id,
@ -337,36 +450,28 @@ class DepartmentDetailView(LoginRequiredMixin, DetailView):
.order_by("employee__last_name", "employee__first_name")[:10]
)
# --- Присоединились за 14 дней: максимум 10
two_weeks_ago = timezone.now() - timedelta(days=14)
has_link_created = any(
f.name == "created_at" for f in EmployeeDepartment._meta.fields
)
if has_link_created:
recent_links = base_links.filter(created_at__gte=two_weeks_ago).order_by(
"-created_at"
)[:10]
else:
recent_links = base_links.filter(
employee__created_at__gte=two_weeks_ago
).order_by("-employee__created_at")[:10]
# --- Присоединились за 30 дней: максимум 10 (сравниваем DateField с датой)
month_ago_date = (timezone.now() - timedelta(days=30)).date()
recent_links = base_links.filter(date_from__gte=month_ago_date).order_by(
"-date_from"
)[:10]
# --- Лента отдела с аннотациями (для сердца/счётчика/последнего коммента)
is_liked_annot = (
Exists(PostLike.objects.filter(post=OuterRef("pk"), user=self.request.user))
if self.request.user.is_authenticated
else Value(False)
else Value(False, output_field=BooleanField())
)
last_comment_qs = Comment.objects.select_related("author").order_by(
"-created_at"
)[:1]
)
posts = (
Post.objects.filter(type="department", department=department)
Post.objects.filter(type=TYPE_DEPARTMENT, department=department)
.select_related("author", "department")
.annotate(is_liked=is_liked_annot, comments_count=Count("comments"))
.prefetch_related(
Prefetch(
"comments", queryset=last_comment_qs, to_attr="last_comment_list"
"comments", queryset=last_comment_qs, to_attr="comments_sorted"
)
)
.order_by("-pinned", "-created_at")
@ -474,10 +579,14 @@ def invite_to_department(request, pk):
employee = form.cleaned_data["employee"]
emp_dep, created = EmployeeDepartment.objects.get_or_create(
employee=employee, department=department, defaults={"is_active": True}
employee=employee,
department=department,
defaults={"is_active": True, "date_from": timezone.now().date()},
)
if not created and not emp_dep.is_active:
emp_dep.is_active = True
emp_dep.date_from = timezone.now().date()
emp_dep.date_to = None
emp_dep.save()
messages.success(request, f"{employee.get_full_name()} приглашён в отдел!")
@ -554,28 +663,28 @@ class AbsenceDeleteView(BaseCRUDDeleteView):
success_url = reverse_lazy("employees:absence_list")
# Skill CRUD
class SkillListView(LoginRequiredMixin, ListView):
# Skill CRUD (через базовые вьюхи, чтобы ограничить видимость своими записями)
class SkillListView(BaseCRUDListView):
model = Skill
template_name = "employees/skill_list.html"
context_object_name = "skills"
class SkillCreateView(LoginRequiredMixin, CreateView):
class SkillCreateView(BaseCRUDCreateView):
model = Skill
form_class = SkillForm
template_name = "employees/skill_form.html"
success_url = reverse_lazy("employees:skill_list")
class SkillUpdateView(LoginRequiredMixin, UpdateView):
class SkillUpdateView(BaseCRUDUpdateView):
model = Skill
form_class = SkillForm
template_name = "employees/skill_form.html"
success_url = reverse_lazy("employees:skill_list")
class SkillDeleteView(LoginRequiredMixin, DeleteView):
class SkillDeleteView(BaseCRUDDeleteView):
model = Skill
template_name = "employees/skill_confirm_delete.html"
success_url = reverse_lazy("employees:skill_list")

View File

@ -0,0 +1,98 @@
# eusrr_backend/auth_backends.py
import re
from typing import Optional
from django.contrib.auth.backends import ModelBackend
from django.conf import settings
from employees.models import Employee # если модель в другом месте — поправь импорт
# phonenumbers — опционально, но очень желательно: pip install phonenumbers
try:
import phonenumbers
from phonenumbers import PhoneNumberFormat
except Exception:
phonenumbers = None
PhoneNumberFormat = None
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _looks_like_email(s: str) -> bool:
return bool(EMAIL_RE.match(s or ""))
def _normalize_phone(raw: str) -> Optional[str]:
"""
Приводим номер к формату E.164 (+79991234567).
Возвращаем None, если распарсить не удалось или пакет не установлен.
"""
if not raw:
return None
if phonenumbers is None:
# fallback — без нормализации
return str(raw).strip()
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
try:
pn = phonenumbers.parse(str(raw), region)
if not phonenumbers.is_valid_number(pn):
return None
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
except Exception:
return None
def _detect_phone_field() -> Optional[str]:
"""
Определяем имя поля телефона в модели Employee.
Поддерживаем распространённые варианты.
"""
candidates = ("phone", "phone_number", "mobile", "msisdn", "tel")
field_names = {f.name for f in Employee._meta.get_fields()}
for name in candidates:
if name in field_names:
return name
return None
PHONE_FIELD = _detect_phone_field()
class EmailOrPhoneBackend(ModelBackend):
"""
Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля.
Пользователь должен быть активен (is_active=True) — активируйте после verify_email().
"""
def authenticate(self, request, username=None, password=None, **kwargs):
login = username or kwargs.get("email") or kwargs.get("phone")
if not login or not password:
return None
user: Optional[Employee] = None
# 1) Email
if _looks_like_email(login):
user = Employee.objects.filter(email__iexact=login).first()
else:
# 2) Phone
if PHONE_FIELD:
normalized = _normalize_phone(login)
# сначала пробуем нормализованный E.164
if normalized:
user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first()
# если не нашли — пробуем как ввёл пользователь (на случай старых данных)
if not user:
raw = str(login).strip()
user = Employee.objects.filter(**{PHONE_FIELD: raw}).first()
if not user:
return None
# допускаем логин только активных (email подтверждён → is_active=True)
if not user.is_active:
return None
if user.check_password(password):
return user
return None

View File

@ -0,0 +1,20 @@
# backend/employees/forms_auth.py
from django import forms
from django.contrib.auth.forms import AuthenticationForm
class EmailOrPhoneAuthenticationForm(AuthenticationForm):
username = forms.CharField(
label="Email или телефон",
widget=forms.TextInput(
attrs={
"autofocus": True,
"placeholder": "user@example.com или +7 999 123-45-67",
}
),
)
password = forms.CharField(
label="Пароль",
strip=False,
widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
)

View File

@ -11,9 +11,14 @@ class AuthRequiredMiddleware:
def __call__(self, request):
allowed_paths = [
reverse('login'),
reverse('register'),
reverse('sms_verify'),
reverse('resend_sms')
reverse('employees:register'),
reverse('employees:email_verify'),
reverse('employees:resend_email'),
reverse('api_v1:register'),
reverse('api_v1:resend-email'),
reverse('api_v1:verify-email')
]
if not request.user.is_authenticated and request.path not in allowed_paths:
return redirect('login')

View File

@ -8,11 +8,10 @@ load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure--kftc-d47!6m2ib6jof3^d%n1&4k%mdf33brejsyg*@il#$-52"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key")
DEBUG = os.getenv("DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
INSTALLED_APPS = [
@ -28,6 +27,7 @@ INSTALLED_APPS = [
"django.contrib.postgres",
"widget_tweaks",
"simple_history",
"rest_framework",
"employees.apps.EmployeesConfig",
"api.apps.ApiConfig",
"hikcentral.apps.HikcentralConfig",
@ -139,13 +139,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "employees.Employee"
LOGIN_URL = "register"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "/"
# LOGOUT_REDIRECT_URL = 'login'
REGISTRATION_AUTO_LOGIN = True
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
@ -199,10 +199,21 @@ CSRF_TRUSTED_ORIGINS = os.getenv(
"CSRF_TRUSTED_ORIGINS", default="https://*.sytes.net"
).split(",")
ACCOUNT_EMAIL_VERIFICATION = "mandatory" # для allauth, если вдруг используешь
DEFAULT_FROM_EMAIL = "noreply@yourdomain.com"
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "465"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true"
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost")
ASGI_APPLICATION = "eusrr_backend.asgi.application"
AUTHENTICATION_BACKENDS = [
"eusrr_backend.auth_backends.EmailOrPhoneBackend",
"django.contrib.auth.backends.ModelBackend",
]
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
@ -211,3 +222,20 @@ CHANNEL_LAYERS = {
},
}
}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication", # куки/сессия
# "rest_framework.authentication.BasicAuthentication", # опц., только в DEV
# "rest_framework_simplejwt.authentication.JWTAuthentication", # если решишь добавить JWT
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny" # оставим как у тебя для плавной миграции
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/min",
},
}

View File

@ -4,14 +4,34 @@ from django.contrib import admin
from django.urls import include, path, reverse_lazy
from django.views.generic.edit import CreateView
from employees.forms import RegistrationForm
from employees.views_front import RegisterView, SMSVerifyView, resend_sms
from employees.views_front import RegisterView
from . import views_auth
urlpatterns = [
path("admin/", admin.site.urls),
path("auth/", include("django.contrib.auth.urls")),
path("auth/register/", RegisterView.as_view(), name="register"),
path("sms-verify/", SMSVerifyView.as_view(), name="sms_verify"),
path("resend-sms/", resend_sms, name="resend_sms"),
path("auth/login/", views_auth.EmailOrPhoneLoginView.as_view(), name="login"),
path("auth/logout/", views_auth.ConfirmLogoutView.as_view(), name="logout"),
path("auth/password-reset/", views_auth.PasswordResetView.as_view(
template_name="registration/password_reset_form.html",
email_template_name="registration/password_reset_email.txt",
subject_template_name="registration/password_reset_subject.txt",
success_url="/auth/password-reset/done/",
), name="password_reset"),
path("auth/password-reset/done/", views_auth.PasswordResetDoneView.as_view(
template_name="registration/password_reset_done.html",
), name="password_reset_done"),
path("auth/reset/<uidb64>/<token>/", views_auth.PasswordResetConfirmView.as_view(
template_name="registration/password_reset_confirm.html",
success_url="/auth/reset/done/",
), name="password_reset_confirm"),
path("auth/reset/done/", views_auth.PasswordResetCompleteView.as_view(
template_name="registration/password_reset_complete.html",
), name="password_reset_complete"),
# path("auth/", include("django.contrib.auth.urls")),
# path("auth/register/", RegisterView.as_view(), name="register"),
# path("sms-verify/", SMSVerifyView.as_view(), name="sms_verify"),
# path("resend-sms/", resend_sms, name="resend_sms"),
path("calendar/", include("calendar_app.urls", namespace="calendar")),
path("documents/", include("documents.urls", namespace="documents")),
path("requests/", include("requests_app.urls", namespace="requests_app")),

View File

@ -0,0 +1,77 @@
# employees/views_auth.py
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import (
LoginView as DjangoLoginView,
PasswordResetView as DjangoPasswordResetView,
PasswordResetDoneView as DjangoPasswordResetDoneView,
PasswordResetConfirmView as DjangoPasswordResetConfirmView,
PasswordResetCompleteView as DjangoPasswordResetCompleteView,
)
from django.shortcuts import redirect, render
from django.views import View
from .forms_auth import EmailOrPhoneAuthenticationForm
class EmailOrPhoneLoginView(DjangoLoginView):
"""
Страница входа: одно поле 'Email или телефон' + пароль.
Использует наш кастомный AuthenticationForm.
"""
template_name = "registration/login.html"
authentication_form = EmailOrPhoneAuthenticationForm
class ConfirmLogoutView(LoginRequiredMixin, View):
template_name = "registration/logout.html" # или logout_confirm.html — под твой файл
def get(self, request):
next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or ""
return render(request, self.template_name, {"next": next_url})
def post(self, request):
next_url = request.POST.get("next") or "login"
logout(request)
messages.success(request, "Вы вышли из аккаунта.")
return redirect(next_url)
# ===== Поток сброса пароля =====
class PasswordResetView(DjangoPasswordResetView):
"""
Шаг 1: форма запроса сброса пароля по email.
"""
template_name = "registration/password_reset_form.html"
email_template_name = "registration/password_reset_email.txt"
subject_template_name = "registration/password_reset_subject.txt"
success_url = "/auth/password-reset/done/"
class PasswordResetDoneView(DjangoPasswordResetDoneView):
"""
Шаг 2: уведомление, что письмо отправлено.
"""
template_name = "registration/password_reset_done.html"
class PasswordResetConfirmView(DjangoPasswordResetConfirmView):
"""
Шаг 3: установка нового пароля по ссылке из письма.
"""
template_name = "registration/password_reset_confirm.html"
success_url = "/auth/reset/done/"
class PasswordResetCompleteView(DjangoPasswordResetCompleteView):
"""
Шаг 4: пароль изменён.
"""
template_name = "registration/password_reset_complete.html"

View File

@ -89,7 +89,7 @@ def feed_list(request):
reverse=True,
)
new_count = max(1, int(len(active_employees) * 0.1))
new_employees = Employee.get_active()[:new_count]
new_employees = Employee.objects.get_active()[:new_count]
return render(
request,

View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<body style="font-family:Arial,Helvetica,sans-serif; line-height:1.5; color:#111;">
<h2>Подтверждение регистрации</h2>
<p>Здравствуйте{{ user.get_full_name|default_if_none:"" }}!</p>
<p>Ваш код подтверждения:</p>
<div style="font-size:24px; font-weight:700; letter-spacing:4px; padding:12px 0;">
{{ code }}
</div>
<p>Скопируйте код и введите на сайте, чтобы завершить регистрацию.</p>
<hr style="border:none; border-top:1px solid #ddd; margin:16px 0;">
<p style="font-size:12px; color:#666;">Если вы не запрашивали этот код, просто игнорируйте письмо.</p>
</body>
</html>

View File

@ -120,18 +120,15 @@
<li><a class="dropdown-item" href="{% url 'employees:profile' %}">
<i class="bi-person me-2"></i>Профиль</a></li>
<li>
<form action="{% url 'logout' %}" method="post" class="m-0 p-0">
{% csrf_token %}
<button type="submit" class="dropdown-item">
<i class="bi-box-arrow-right me-2"></i>Выйти
</button>
</form>
<a class="dropdown-item" href="{% url 'logout' %}">
<i class="bi-box-arrow-right me-2"></i>Выйти
</a>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-outline-light rounded-pill me-2" href="{% url 'login' %}">Войти</a>
<a class="btn btn-light rounded-pill" href="{% url 'register' %}">Регистрация</a>
<a class="btn btn-light rounded-pill" href="{% url 'employees:register' %}">Регистрация</a>
{% endif %}
</div>

View File

@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block extra_css %}
<!-- Стили для iOS-подобного вида: светлый фон, закругленные элементы, поддержка тем -->
<style>
body {
background-color: #f0f2f5; /* Light mode */
color: #050505;
transition: background-color 0.3s, color 0.3s;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body.dark-mode {
background-color: #18191a; /* Dark mode */
color: #e4e6eb;
}
.verify-container {
max-width: 400px;
margin: 50px auto;
padding: 20px;
background-color: white;
border-radius: 12px; /* Закругленные углы как в iOS */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
body.dark-mode .verify-container {
background-color: #242526;
}
.verify-title {
font-size: 24px;
font-weight: bold;
margin-bottom: 10px;
}
.verify-description {
font-size: 16px;
color: #606770;
margin-bottom: 20px;
}
body.dark-mode .verify-description {
color: #b0b3b8;
}
.verify-form input {
width: 100%;
padding: 12px;
border: 1px solid #dddfe2;
border-radius: 8px;
margin-bottom: 15px;
font-size: 16px;
background-color: white;
color: #050505;
}
body.dark-mode .verify-form input {
background-color: #3a3b3c;
color: #e4e6eb;
border: 1px solid #3a3b3c;
}
.verify-button {
background-color: #1877f2; /* Синий как в Facebook/Instagram */
color: white;
border: none;
border-radius: 8px;
padding: 12px;
font-size: 16px;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.verify-button:hover {
background-color: #166fe5;
}
.resend-link {
display: block;
margin-top: 15px;
color: #1877f2;
text-decoration: none;
font-size: 14px;
}
</style>
{% endblock %}
{% block content %}
<div class="verify-container">
<div class="verify-title">Верификация email</div>
<div class="verify-description">
Мы отправили код на ваш email {{ email }}. Введите его ниже для подтверждения.
</div>
<form method="post" class="verify-form">
{% csrf_token %}
<label for="id_code">Код верификации</label>
<input type="text" name="code" id="id_code" maxlength="6" placeholder="Введите код" required>
<button type="submit" class="verify-button">Подтвердить</button>
</form>
<a href="{% url 'employees:resend_email' %}" class="resend-link">Отправить код заново</a>
</div>
{% endblock %}
{% block extra_js %}
<!-- JS для фокуса на поле и анимаций -->
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('input[name="code"]').focus();
});
</script>
{% endblock %}

View File

@ -30,11 +30,11 @@
name="username"
class="form-control {% if form.username.errors %}is-invalid{% endif %}"
id="id_username"
placeholder="+7 (999) 123-45-67"
value="{{ form.username.value|default:'' }}"
required
autofocus>
<label for="id_username">Номер телефона</label>
<label for="id_username">Номер телефона или email</label>
{% if form.username.errors %}
<div class="invalid-feedback">
{{ form.username.errors|striptags }}

View File

@ -1,33 +1,171 @@
{% extends 'base.html' %}
{% load bootstrap5 %}
{# templates/registration/logout.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}Выход из системы{% endblock %}
{% block title %}Выход{% endblock %}
{% block extra_css %}
<style>
/* Overlay поверх всего (и навбара тоже) */
.ios-overlay{
position: fixed; inset: 0; z-index: 1080;
background: color-mix(in srgb, var(--bs-body-bg), #000 35%);
backdrop-filter: blur(10px) saturate(110%);
display: grid; place-items: end center; /* bottom sheet по умолчанию */
}
@media (min-width: 992px){
.ios-overlay{ place-items: center; }
}
/* Bottom sheet / Card */
.ios-sheet{
width: 100%; max-width: 560px;
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
border-radius: 16px 16px 0 0;
box-shadow: 0 12px 48px rgba(0,0,0,.28);
animation: slideUp .28s ease both;
}
@media (min-width: 992px){
.ios-sheet{
border-radius: 18px; animation: popIn .18s ease both;
}
}
/* Грип-бар как в iOS */
.ios-grip{
width: 36px; height: 4px; border-radius: 999px;
background: color-mix(in srgb, var(--bs-body-color), transparent 70%);
margin: .5rem auto 0.25rem auto;
}
/* Контент */
.ios-body{ padding: 1rem 1.25rem 1.25rem 1.25rem; }
@media (min-width: 992px){ .ios-body{ padding: 1.25rem 1.5rem 1.5rem 1.5rem; } }
.ios-title{
margin: .25rem 0 .25rem 0; text-align: center;
font-weight: 800; letter-spacing: .2px;
}
.ios-sub{
text-align: center; color: var(--bs-secondary-color);
margin: 0 0 1rem 0;
}
/* Аватар + иконка выхода */
.ios-ava{
width: 64px; height: 64px; border-radius: 999px; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: var(--bs-tertiary-bg);
display: grid; place-items: center; margin: .25rem auto .75rem auto;
}
.ios-ava img{ width: 100%; height: 100%; object-fit: cover; display: block; }
.ios-ico{
width: 56px; height: 56px; border-radius: 14px;
background: color-mix(in srgb, var(--bs-danger), #000 12%);
color: #fff; display: grid; place-items: center; margin: .25rem auto .75rem auto;
}
/* Кнопки */
.ios-actions{ display: grid; gap: .6rem; }
.ios-btn{ min-height: 48px; border-radius: 12px; font-weight: 700; }
.ios-btn-danger{
background: var(--bs-danger); border-color: var(--bs-danger); color: #fff;
}
.ios-btn-danger:hover{ filter: brightness(1.02); }
.ios-btn-ghost{
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
color: var(--bs-body-color);
}
.ios-btn-ghost:hover{ background: color-mix(in srgb, var(--bs-body-bg), #fff 9%); }
/* Анимации */
@keyframes slideUp{
from{ transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes popIn{
from{ transform: scale(.98); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow">
<div class="card-header bg-danger text-white">
<h3 class="mb-0">Выход из системы</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<div class="text-center mb-4">
<p class="lead">Вы уверены, что хотите выйти из системы?</p>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger btn-lg">
<i class="bi bi-power me-2"></i>Выйти
</button>
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary btn-lg">Остаться</a>
</div>
</form>
</div>
</div>
{# Перекрываем основной контент полноэкранным диалогом #}
<div class="ios-overlay" role="dialog" aria-modal="true" aria-labelledby="logoutTitle" aria-describedby="logoutText">
<div class="ios-sheet">
<div class="ios-grip" aria-hidden="true"></div>
<div class="ios-body">
{# аватар если есть, иначе красивая иконка выхода #}
{% if user.avatar %}
<div class="ios-ava">
<img src="{{ user.avatar.url }}" alt="">
</div>
{% else %}
<div class="ios-ico">
<i class="bi-box-arrow-right fs-3"></i>
</div>
{% endif %}
<h1 id="logoutTitle" class="ios-title">Выйти из аккаунта?</h1>
<p id="logoutText" class="ios-sub">
{{ user.get_short_name|default:"Вы" }}, вы уверены, что хотите завершить сеанс?<br>
Вы всегда можете войти снова — данные не удаляются.
</p>
<form method="post" class="ios-actions" autocomplete="off">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next|default:'' }}">
<button type="submit" class="btn ios-btn ios-btn-danger" id="confirmBtn">
Выйти
</button>
<a href="javascript:void(0)" class="btn ios-btn ios-btn-ghost text-decoration-none text-center" id="cancelBtn">
Отмена
</a>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// UX: ESC = отмена, Enter на «Выйти», тап по фону = отмена
(function(){
const cancel = document.getElementById('cancelBtn');
const confirm = document.getElementById('confirmBtn');
const overlay = document.querySelector('.ios-overlay');
function goBack(){
// если есть referer/next — туда, иначе на главную
const params = new URLSearchParams(location.search);
const next = (params.get('next') || '').trim();
if (next) { location.href = next; return; }
if (document.referrer) { history.back(); return; }
location.href = "{% url 'login' %}";
}
cancel?.addEventListener('click', goBack);
// клик по фону закрывает (но не по самой карточке)
overlay?.addEventListener('click', (e)=>{
if (e.target === overlay) goBack();
});
addEventListener('keydown', (e)=>{
if (e.key === 'Escape'){ e.preventDefault(); goBack(); }
if (e.key === 'Enter' && document.activeElement !== cancel){
// даём нативной форме отправиться
}
});
// автофокус на «Выйти»
setTimeout(()=> confirm?.focus(), 10);
})();
</script>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% load static %}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Подтверждение выхода</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- если используешь bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container py-5">
<div class="card shadow-sm mx-auto" style="max-width:480px;">
<div class="card-body">
<h1 class="h4 mb-3">Выйти из аккаунта?</h1>
<p class="text-secondary mb-4">Вы уверены, что хотите завершить сеанс?</p>
<form method="post">
{% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% else %}
<input type="hidden" name="next" value="{% url 'login' %}">
{% endif %}
<div class="d-flex gap-2">
<a href="javascript:history.back()" class="btn btn-outline-secondary">Отмена</a>
<button type="submit" class="btn btn-danger">Выйти</button>
</div>
</form>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
<h1>Пароль изменён</h1>
<a href="{% url 'login' %}">Войти</a>

View File

@ -0,0 +1,6 @@
Вы запросили сброс пароля на {{ site_name }}.
Перейдите по ссылке для смены пароля:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
Если вы не запрашивали сброс пароля, просто игнорируйте это письмо.

View File

@ -0,0 +1,7 @@
<form method="post">
{% csrf_token %}
<h1>Сброс пароля</h1>
<p>Укажите e-mail вашей учетной записи.</p>
{{ form.email.label_tag }} {{ form.email }}
<button type="submit">Отправить ссылку</button>
</form>

View File

@ -0,0 +1 @@
Сброс пароля на {{ site_name }}

View File

@ -26,22 +26,28 @@
</div>
</div>
<!-- Мессенджеры -->
<div class="row g-3 mb-4 align-items-end">
<div class="col-md-6 position-relative">
<!-- Мессенджеры (добавлен WeChat) -->
<div class="row g-3 mb-4">
<div class="col-md-4 position-relative">
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-whatsapp text-success"></i>
</span>
</div>
<div class="col-md-6 position-relative">
<div class="col-md-4 position-relative">
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-telegram text-primary"></i>
</span>
</div>
<div class="col-md-4 position-relative">
{% bootstrap_field form.wechat show_label=False placeholder="WeChat" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-wechat" style="color: #07C160;"></i>
</span>
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4">
<div class="alert alert-danger alert-dismissible fade show mb-4" style="border-radius: 8px;">
{% for error in form.non_field_errors %}
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
{% endfor %}