446 lines
16 KiB
Python
446 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import timedelta
|
||
|
||
from api.v1.employees.serializers import (
|
||
EmailSerializer,
|
||
EmailVerifySerializer,
|
||
RegisterSerializer,
|
||
)
|
||
from api.v1.employees.views._helpers import Employee
|
||
from api.v1.employees.views.mixins import LdapUserCreationMixin
|
||
from common.emails import send_templated_mail
|
||
from django.core.files.base import ContentFile
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
from django.utils.crypto import get_random_string
|
||
from drf_spectacular.utils import (
|
||
OpenApiExample,
|
||
OpenApiParameter,
|
||
OpenApiResponse,
|
||
extend_schema,
|
||
inline_serializer,
|
||
)
|
||
from employees.models import Position, Skill
|
||
from employees.utils import _normalize_phone
|
||
from rest_framework import serializers, status
|
||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||
|
||
from .models import UserAuthSession
|
||
from .serializers import (
|
||
PhoneOrEmailTokenObtainPairSerializer,
|
||
SessionBulkActionResponseSerializer,
|
||
SessionSerializer,
|
||
SessionTokenRefreshSerializer,
|
||
TokenPairResponseSerializer,
|
||
TokenRefreshRequestSerializer,
|
||
TokenRefreshResponseSerializer,
|
||
)
|
||
from .services import SESSION_ID_CLAIM
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AnonymousAPIView(APIView):
|
||
authentication_classes = []
|
||
throttle_scope = "anon"
|
||
permission_classes = [AllowAny]
|
||
|
||
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Получить JWT access и refresh токены",
|
||
description=(
|
||
"Принимает email или телефон вместе с паролем. "
|
||
"Если передан телефон, backend найдёт пользователя "
|
||
"и использует его email "
|
||
"для стандартной JWT-аутентификации."
|
||
),
|
||
request=PhoneOrEmailTokenObtainPairSerializer,
|
||
responses={
|
||
200: TokenPairResponseSerializer,
|
||
401: OpenApiResponse(
|
||
description=(
|
||
"Неверные учетные данные или email пользователя "
|
||
"не подтвержден."
|
||
),
|
||
),
|
||
},
|
||
examples=[
|
||
OpenApiExample(
|
||
"Вход по email",
|
||
value={"email": "user@example.com", "password": "Str0ngPass!"},
|
||
request_only=True,
|
||
),
|
||
OpenApiExample(
|
||
"Вход по телефону",
|
||
value={"phone_number": "+79990000001", "password": "Str0ngPass!"},
|
||
request_only=True,
|
||
),
|
||
],
|
||
)
|
||
class PhoneOrEmailTokenObtainPairView(TokenObtainPairView):
|
||
serializer_class = PhoneOrEmailTokenObtainPairSerializer
|
||
|
||
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Обновить JWT access token",
|
||
description="Принимает refresh token и возвращает новый access token.",
|
||
request=TokenRefreshRequestSerializer,
|
||
responses={
|
||
200: TokenRefreshResponseSerializer,
|
||
401: OpenApiResponse(
|
||
description="Refresh token недействителен, истек или сессия отозвана."
|
||
),
|
||
},
|
||
)
|
||
class JWTTokenRefreshView(TokenRefreshView):
|
||
serializer_class = SessionTokenRefreshSerializer
|
||
|
||
|
||
class ResendEmailAPIView(AnonymousAPIView):
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Повторно отправить код подтверждения email",
|
||
request=EmailSerializer,
|
||
responses={
|
||
200: inline_serializer(
|
||
"ResendEmailResponse",
|
||
{"ok": serializers.BooleanField()},
|
||
),
|
||
400: OpenApiResponse(description="Email уже подтвержден."),
|
||
404: OpenApiResponse(description="Пользователь не найден."),
|
||
},
|
||
)
|
||
def post(self, request):
|
||
ser = EmailSerializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
email = ser.validated_data["email"]
|
||
|
||
user = Employee.objects.filter(email__iexact=email).first()
|
||
if not user:
|
||
return Response({"ok": False, "error": "user_not_found"}, status=404)
|
||
if user.email_verified:
|
||
return Response({"ok": False, "error": "already_verified"}, status=400)
|
||
|
||
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)
|
||
|
||
|
||
class VerifyEmailAPIView(AnonymousAPIView):
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Подтвердить email кодом",
|
||
request=EmailVerifySerializer,
|
||
responses={
|
||
200: inline_serializer(
|
||
"VerifyEmailResponse",
|
||
{
|
||
"ok": serializers.BooleanField(),
|
||
"user_id": serializers.IntegerField(),
|
||
},
|
||
),
|
||
400: OpenApiResponse(description="Код пустой, неверный или истек."),
|
||
404: OpenApiResponse(description="Пользователь не найден."),
|
||
},
|
||
)
|
||
def post(self, request):
|
||
ser = EmailVerifySerializer(data=request.data)
|
||
ser.is_valid(raise_exception=True)
|
||
email = ser.validated_data["email"].strip().lower()
|
||
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)
|
||
|
||
created = getattr(user, "created_at", None) or getattr(
|
||
user, "date_joined", None
|
||
)
|
||
if created and not user.email_verified:
|
||
if timezone.now() - created > timedelta(minutes=5):
|
||
user.delete()
|
||
return Response({"ok": False, "error": "expired"}, status=400)
|
||
|
||
if not user.verify_email(code):
|
||
return Response({"ok": False, "error": "invalid_code"}, status=400)
|
||
|
||
user.is_active = True
|
||
user._ldap_changes = {"is_active": True}
|
||
user.save()
|
||
|
||
return Response({"ok": True, "user_id": user.id}, status=200)
|
||
|
||
|
||
class RegisterAPIView(LdapUserCreationMixin, AnonymousAPIView):
|
||
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||
|
||
def is_ldap_enabled(self) -> bool:
|
||
# Сохраняем старую точку patch'а тестов:
|
||
# api.v1.employees.views._is_ldap_enabled
|
||
from api.v1.employees import views as employee_views
|
||
|
||
return employee_views._is_ldap_enabled()
|
||
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Зарегистрировать нового пользователя",
|
||
request=RegisterSerializer,
|
||
responses={
|
||
201: inline_serializer(
|
||
"RegisterResponse",
|
||
{
|
||
"id": serializers.IntegerField(),
|
||
"email": serializers.EmailField(),
|
||
"email_verified": serializers.BooleanField(),
|
||
"is_active": serializers.BooleanField(),
|
||
},
|
||
),
|
||
200: inline_serializer(
|
||
"RegisterPendingVerificationResponse",
|
||
{
|
||
"ok": serializers.BooleanField(),
|
||
"pending_verification": serializers.BooleanField(),
|
||
"user_id": serializers.IntegerField(),
|
||
},
|
||
),
|
||
400: OpenApiResponse(
|
||
description="Ошибка валидации или занятый email/телефон."
|
||
),
|
||
},
|
||
)
|
||
@transaction.atomic
|
||
def post(self, request):
|
||
logger.warning("[REGISTER] Received data: %s", request.data)
|
||
logger.warning("[REGISTER] Content-Type: %s", request.content_type)
|
||
|
||
ser = RegisterSerializer(data=request.data)
|
||
if not ser.is_valid():
|
||
logger.warning("[REGISTER] Validation errors: %s", ser.errors)
|
||
ser.is_valid(raise_exception=True)
|
||
v = ser.validated_data
|
||
|
||
email = v["email"].strip().lower()
|
||
password = v["password"]
|
||
phone_norm = _normalize_phone(
|
||
v.get("phone_number") or request.data.get("phone")
|
||
)
|
||
logger.warning(
|
||
"[REGISTER] Phone normalization: input=%s, normalized=%s",
|
||
v.get("phone_number"),
|
||
phone_norm,
|
||
)
|
||
if not phone_norm:
|
||
logger.error(
|
||
"[REGISTER] Phone normalization FAILED for: %s",
|
||
v.get("phone_number"),
|
||
)
|
||
return Response({"ok": False, "error": "invalid_phone"}, status=400)
|
||
|
||
existing_phone = Employee.objects.filter(phone_number=phone_norm).first()
|
||
if existing_phone:
|
||
logger.warning(
|
||
"[REGISTER] Phone %s already used by user id=%s",
|
||
phone_norm,
|
||
existing_phone.id,
|
||
)
|
||
return Response(
|
||
{
|
||
"ok": False,
|
||
"error": "phone_taken",
|
||
"detail": (
|
||
"Номер телефона уже зарегистрирован. "
|
||
"Войдите в существующий аккаунт "
|
||
"или используйте другой номер."
|
||
),
|
||
"phone_number": [
|
||
"Этот номер телефона уже привязан к другому аккаунту."
|
||
],
|
||
},
|
||
status=400,
|
||
)
|
||
|
||
user = Employee.objects.filter(email__iexact=email).first()
|
||
if user:
|
||
logger.warning(
|
||
"[REGISTER] User exists: email=%s, verified=%s",
|
||
email,
|
||
user.email_verified,
|
||
)
|
||
if user.email_verified:
|
||
logger.error("[REGISTER] Email already taken and verified: %s", email)
|
||
return Response({"ok": False, "error": "email_taken"}, status=400)
|
||
return Response(
|
||
{
|
||
"ok": True,
|
||
"pending_verification": True,
|
||
"user_id": user.id,
|
||
},
|
||
status=200,
|
||
)
|
||
|
||
avatar_file = v.get("avatar") or getattr(request, "FILES", {}).get("avatar")
|
||
avatar_bytes = None
|
||
avatar_name = None
|
||
if avatar_file:
|
||
try:
|
||
avatar_bytes = (
|
||
avatar_file.read() if hasattr(avatar_file, "read") else None
|
||
)
|
||
avatar_name = getattr(avatar_file, "name", None) or "avatar.jpg"
|
||
except Exception:
|
||
avatar_bytes = None
|
||
avatar_name = None
|
||
|
||
emp, error_response = self.create_user(
|
||
first_name=v["first_name"],
|
||
last_name=v["last_name"],
|
||
email=email,
|
||
phone=phone_norm,
|
||
password=password,
|
||
avatar_bytes=avatar_bytes,
|
||
is_active=False,
|
||
)
|
||
if error_response:
|
||
return error_response
|
||
|
||
if avatar_bytes:
|
||
try:
|
||
emp.avatar.save(avatar_name, ContentFile(avatar_bytes), save=False)
|
||
except Exception:
|
||
pass
|
||
|
||
emp.telegram = v.get("telegram", "")
|
||
emp.whatsapp = v.get("whatsapp", "")
|
||
emp.wechat = v.get("wechat", "")
|
||
emp.birth_date = v["birth_date"]
|
||
emp.gender = v.get("gender")
|
||
|
||
if v.get("patronymic"):
|
||
emp.patronymic = v["patronymic"]
|
||
|
||
pos_id = v.get("position")
|
||
if pos_id and Position.objects.filter(pk=pos_id).exists():
|
||
emp.position_id = pos_id
|
||
|
||
emp.save()
|
||
|
||
skills_ids = v.get("skills") or []
|
||
if skills_ids:
|
||
emp.skills.set(Skill.objects.filter(pk__in=skills_ids))
|
||
|
||
emp.email_activation_code = get_random_string(6, "0123456789")
|
||
emp.save(update_fields=["email_activation_code"])
|
||
send_templated_mail(
|
||
subject="Подтверждение регистрации",
|
||
to=[emp.email],
|
||
template_base="emails/registration_verify_code",
|
||
context={"code": emp.email_activation_code, "user": emp},
|
||
)
|
||
|
||
return Response(
|
||
{
|
||
"id": emp.id,
|
||
"email": emp.email,
|
||
"email_verified": emp.email_verified,
|
||
"is_active": emp.is_active,
|
||
},
|
||
status=status.HTTP_201_CREATED,
|
||
)
|
||
|
||
|
||
class SessionBaseAPIView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def get_current_session_id(self, request) -> str | None:
|
||
token = getattr(request, "auth", None)
|
||
if token is None:
|
||
return None
|
||
return token.get(SESSION_ID_CLAIM)
|
||
|
||
|
||
class SessionListAPIView(SessionBaseAPIView):
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Список активных сессий пользователя",
|
||
responses={200: SessionSerializer(many=True)},
|
||
)
|
||
def get(self, request):
|
||
current_session_id = self.get_current_session_id(request)
|
||
queryset = UserAuthSession.objects.filter(
|
||
user=request.user,
|
||
revoked_at__isnull=True,
|
||
).order_by("-last_seen_at", "-created_at")
|
||
serializer = SessionSerializer(
|
||
queryset,
|
||
many=True,
|
||
context={"current_session_id": current_session_id},
|
||
)
|
||
return Response(serializer.data)
|
||
|
||
|
||
class SessionDetailAPIView(SessionBaseAPIView):
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Завершить одну сессию",
|
||
parameters=[
|
||
OpenApiParameter(
|
||
name="session_id",
|
||
type=str,
|
||
location=OpenApiParameter.PATH,
|
||
)
|
||
],
|
||
responses={204: OpenApiResponse(description="Сессия завершена.")},
|
||
)
|
||
def delete(self, request, session_id):
|
||
session = UserAuthSession.objects.filter(
|
||
user=request.user,
|
||
session_id=session_id,
|
||
revoked_at__isnull=True,
|
||
).first()
|
||
if not session:
|
||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||
|
||
reason = "self_logout" if str(session.session_id) == str(
|
||
self.get_current_session_id(request)
|
||
) else "manual_logout"
|
||
session.revoke(reason=reason)
|
||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
class LogoutOtherSessionsAPIView(SessionBaseAPIView):
|
||
@extend_schema(
|
||
tags=["Auth"],
|
||
summary="Завершить все остальные сессии",
|
||
responses={200: SessionBulkActionResponseSerializer},
|
||
)
|
||
def post(self, request):
|
||
current_session_id = self.get_current_session_id(request)
|
||
sessions = UserAuthSession.objects.filter(
|
||
user=request.user,
|
||
revoked_at__isnull=True,
|
||
).exclude(session_id=current_session_id)
|
||
|
||
revoked = 0
|
||
for session in sessions:
|
||
session.revoke(reason="logout_others")
|
||
revoked += 1
|
||
|
||
return Response({"revoked": revoked}, status=status.HTTP_200_OK)
|