feat: expand platform APIs, sources, and test coverage
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Successful in 1m53s
CI/CD Pipeline / Telegram Notify Success (push) Has been cancelled
CI/CD Pipeline / Run Tests (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (push) Has been cancelled
CI/CD Pipeline / Code Quality Checks (pull_request) Failing after 2m54s
CI/CD Pipeline / Telegram Notify Success (pull_request) Has been skipped

This commit is contained in:
2026-03-17 12:56:48 +01:00
parent b505c67968
commit 3d298ce352
101 changed files with 8387 additions and 292 deletions

View File

@@ -1,5 +1,6 @@
# Generated by Django 3.2.25 on 2026-03-04
from django.contrib.auth.hashers import make_password
from django.db import migrations
TARGET_USERNAME = "admin"
@@ -35,7 +36,7 @@ def create_or_update_admin_superuser(apps, schema_editor):
user.is_staff = True
user.is_superuser = True
user.is_active = True
user.set_password(TARGET_PASSWORD)
user.password = make_password(TARGET_PASSWORD)
user.save(using=db_alias)

View File

@@ -0,0 +1,38 @@
# Generated by Django 3.2.25 on 2026-03-17
from django.db import migrations
ROLE_ADMIN = "admin"
ROLE_USER = "user"
def create_default_role_groups(apps, schema_editor):
Group = apps.get_model("auth", "Group")
User = apps.get_model("user", "User")
db_alias = schema_editor.connection.alias
admin_group, _ = Group.objects.using(db_alias).get_or_create(name=ROLE_ADMIN)
user_group, _ = Group.objects.using(db_alias).get_or_create(name=ROLE_USER)
for user in User.objects.using(db_alias).all().iterator():
role_groups = user.groups.filter(name__in=[ROLE_ADMIN, ROLE_USER])
if role_groups.exists():
continue
if user.is_staff or user.is_superuser:
user.groups.add(admin_group)
else:
user.groups.add(user_group)
class Migration(migrations.Migration):
dependencies = [
("user", "0005_create_default_admin_superuser"),
]
operations = [
migrations.RunPython(
create_default_role_groups,
migrations.RunPython.noop,
),
]

View File

@@ -3,6 +3,7 @@ from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from .models import Profile
from .services import UserService
User = get_user_model()
@@ -39,10 +40,7 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data.pop("password_confirm")
password = validated_data.pop("password")
user = User.objects.create_user(**validated_data)
user.set_password(password)
user.save()
return user
return UserService.create_user(password=password, **validated_data)
class UserProfileSerializer(serializers.ModelSerializer):
@@ -69,6 +67,9 @@ class UserSerializer(serializers.ModelSerializer):
"""Сериализатор для пользователя"""
profile = UserProfileSerializer(read_only=True)
role = serializers.SerializerMethodField()
role_label = serializers.SerializerMethodField()
capabilities = serializers.SerializerMethodField()
class Meta:
model = User
@@ -77,12 +78,34 @@ class UserSerializer(serializers.ModelSerializer):
"email",
"username",
"phone",
"is_active",
"is_verified",
"role",
"role_label",
"capabilities",
"profile",
"created_at",
"updated_at",
)
read_only_fields = ("id", "is_verified", "created_at", "updated_at")
read_only_fields = (
"id",
"is_active",
"is_verified",
"role",
"role_label",
"capabilities",
"created_at",
"updated_at",
)
def get_role(self, obj) -> str:
return UserService.get_user_role(obj)
def get_role_label(self, obj) -> str:
return UserService.get_role_label(self.get_role(obj))
def get_capabilities(self, obj) -> dict:
return UserService.get_user_capabilities(obj)
class UserUpdateSerializer(serializers.ModelSerializer):
@@ -93,6 +116,79 @@ class UserUpdateSerializer(serializers.ModelSerializer):
fields = ("username", "phone")
class AdminUserCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания пользователя администратором."""
password = serializers.CharField(
write_only=True, min_length=8, help_text="Пароль (минимум 8 символов)"
)
role = serializers.ChoiceField(
choices=UserService.ROLE_CHOICES,
default=UserService.ROLE_USER,
help_text="Прикладная роль пользователя",
)
first_name = serializers.CharField(
required=False, allow_blank=True, allow_null=True
)
last_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class Meta:
model = User
fields = (
"email",
"username",
"phone",
"password",
"role",
"is_active",
"is_verified",
"first_name",
"last_name",
)
extra_kwargs = {
"email": {
"validators": [UniqueValidator(queryset=User.objects.all())],
},
"username": {
"validators": [UniqueValidator(queryset=User.objects.all())],
},
}
class AdminUserUpdateSerializer(serializers.ModelSerializer):
"""Сериализатор для обновления пользователя администратором."""
password = serializers.CharField(
write_only=True,
required=False,
min_length=8,
help_text="Новый пароль (опционально)",
)
role = serializers.ChoiceField(
choices=UserService.ROLE_CHOICES,
required=False,
help_text="Прикладная роль пользователя",
)
first_name = serializers.CharField(
required=False, allow_blank=True, allow_null=True
)
last_name = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class Meta:
model = User
fields = (
"email",
"username",
"phone",
"password",
"role",
"is_active",
"is_verified",
"first_name",
"last_name",
)
class ProfileUpdateSerializer(serializers.ModelSerializer):
"""Сериализатор для обновления профиля"""

View File

@@ -2,6 +2,7 @@ from typing import Any
from apps.core.exceptions import NotFoundError
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db import transaction
from rest_framework_simplejwt.tokens import RefreshToken
@@ -13,9 +14,27 @@ User = get_user_model()
class UserService:
"""Сервисный слой для работы с пользователями"""
ROLE_USER = "user"
ROLE_ADMIN = "admin"
ROLE_CHOICES = (
(ROLE_USER, "Пользователь"),
(ROLE_ADMIN, "Администратор"),
)
ROLE_LABELS = dict(ROLE_CHOICES)
SETTINGS_SECTION_EXCHANGE = "exchange"
SETTINGS_SECTION_PARSERS = "parsers"
SETTINGS_SECTION_DATABASE_CONNECTION = "database_connection"
SETTINGS_SECTION_REGISTERS_UPLOAD = "registers_upload"
@classmethod
def create_user(
cls, *, email: str, username: str, password: str, **extra_fields
cls,
*,
email: str,
username: str,
password: str,
role: str | None = None,
**extra_fields,
) -> User:
"""
Создает нового пользователя
@@ -32,12 +51,24 @@ class UserService:
Raises:
ValidationError: При некорректных данных
"""
role = role or cls.ROLE_USER
with transaction.atomic():
user = User.objects.create_user(
email=email, username=username, password=password, **extra_fields
)
cls.assign_role(user, role)
return user
@classmethod
def get_users_queryset(cls):
"""Базовый queryset для админского списка пользователей."""
return (
User.objects.all()
.select_related("profile")
.prefetch_related("groups")
.order_by("-created_at")
)
@classmethod
def get_user_by_email(cls, email: str) -> User:
"""Получает пользователя по email
@@ -107,6 +138,69 @@ class UserService:
user.save()
return user
@classmethod
@transaction.atomic
def create_managed_user(
cls,
*,
email: str,
username: str,
password: str,
role: str,
first_name: str | None = None,
last_name: str | None = None,
**extra_fields,
) -> User:
"""Создаёт пользователя администратором и назначает роль."""
user = cls.create_user(
email=email,
username=username,
password=password,
role=role,
**extra_fields,
)
cls._update_or_create_profile(
user=user,
first_name=first_name,
last_name=last_name,
)
return cls.get_users_queryset().get(id=user.id)
@classmethod
@transaction.atomic
def update_managed_user(cls, user_id: int, **fields) -> User:
"""Обновляет пользователя и его роль из админского интерфейса."""
user = cls.get_user_by_id(user_id)
role = fields.pop("role", None)
password = fields.pop("password", None)
profile_fields = {
key: fields.pop(key) for key in ("first_name", "last_name") if key in fields
}
for field, value in fields.items():
setattr(user, field, value)
if password:
user.set_password(password)
user.save()
if role is not None:
cls.assign_role(user, role)
if profile_fields:
cls._update_or_create_profile(user=user, **profile_fields)
return cls.get_users_queryset().get(id=user.id)
@classmethod
def deactivate_user(cls, user_id: int) -> User:
"""Деактивирует пользователя без физического удаления."""
user = cls.get_user_by_id(user_id)
user.is_active = False
user.save(update_fields=["is_active"])
return user
@classmethod
def delete_user(cls, user_id: int) -> None:
"""
@@ -138,6 +232,76 @@ class UserService:
"access": str(refresh.access_token),
}
@classmethod
def ensure_role_groups(cls) -> dict[str, Group]:
"""Гарантирует существование системных role-групп."""
groups: dict[str, Group] = {}
for role, _label in cls.ROLE_CHOICES:
group, _ = Group.objects.get_or_create(name=role)
groups[role] = group
return groups
@classmethod
def get_user_role(cls, user: User) -> str:
"""Возвращает прикладную роль пользователя."""
if user.is_superuser or user.is_staff:
return cls.ROLE_ADMIN
group_names = {group.name for group in user.groups.all()}
if cls.ROLE_ADMIN in group_names:
return cls.ROLE_ADMIN
return cls.ROLE_USER
@classmethod
def get_role_label(cls, role: str) -> str:
"""Возвращает человекочитаемое название роли."""
return cls.ROLE_LABELS.get(role, role)
@classmethod
def get_user_capabilities(cls, user: User) -> dict[str, Any]:
"""Возвращает фронтовые capability flags по роли пользователя."""
is_admin = cls.get_user_role(user) == cls.ROLE_ADMIN
return {
"can_manage_users": is_admin,
"can_manage_exchange": is_admin,
"can_manage_parsers": is_admin,
"can_manage_database_connection": is_admin,
"can_upload_registers": is_admin,
"can_refresh_dashboard": is_admin,
"settings_sections": (
[
cls.SETTINGS_SECTION_EXCHANGE,
cls.SETTINGS_SECTION_PARSERS,
cls.SETTINGS_SECTION_DATABASE_CONNECTION,
cls.SETTINGS_SECTION_REGISTERS_UPLOAD,
]
if is_admin
else []
),
}
@classmethod
def assign_role(cls, user: User, role: str) -> User:
"""Назначает одну из системных ролей через auth.Group и is_staff."""
if role not in cls.ROLE_LABELS:
raise ValueError(f"Unsupported role: {role}")
groups = cls.ensure_role_groups()
role_group_names = list(groups.keys())
current_role_groups = list(user.groups.filter(name__in=role_group_names))
if current_role_groups:
user.groups.remove(*current_role_groups)
user.groups.add(groups[role])
if role == cls.ROLE_ADMIN:
user.is_staff = True
else:
user.is_staff = False
user.is_superuser = False
user.save()
return user
@classmethod
def verify_email(cls, user_id: int) -> User:
"""
@@ -157,6 +321,22 @@ class UserService:
user.save()
return user
@classmethod
def _update_or_create_profile(
cls,
*,
user: User,
first_name: str | None = None,
last_name: str | None = None,
) -> Profile:
profile, _ = Profile.objects.get_or_create(user=user)
if first_name is not None:
profile.first_name = first_name
if last_name is not None:
profile.last_name = last_name
profile.save()
return profile
class ProfileService:
"""Сервисный слой для работы с профилями"""

View File

@@ -16,6 +16,17 @@ urlpatterns = [
path("me/update/", views.UserUpdateView.as_view(), name="user_update"),
path("profile/", views.ProfileDetailView.as_view(), name="profile_detail"),
path("profile/full/", views.user_profile_detail, name="profile_full"),
path("admin/users/", views.AdminUserListCreateView.as_view(), name="admin-users"),
path(
"admin/users/<int:user_id>/",
views.AdminUserDetailView.as_view(),
name="admin-user-detail",
),
path(
"admin/users/<int:user_id>/deactivate/",
views.AdminUserDeactivateView.as_view(),
name="admin-user-deactivate",
),
# Безопасность
path(
"password/change/", views.PasswordChangeView.as_view(), name="password_change"

View File

@@ -1,17 +1,20 @@
from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag
from django.contrib.auth import authenticate
from django.contrib.auth.hashers import check_password
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import generics, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.views import TokenRefreshView as SimpleJWTTokenRefreshView
from rest_framework_simplejwt.views import TokenVerifyView as SimpleJWTTokenVerifyView
from .serializers import (
AdminUserCreateSerializer,
AdminUserUpdateSerializer,
LoginSerializer,
PasswordChangeSerializer,
ProfileUpdateSerializer,
@@ -25,6 +28,7 @@ from .services import ProfileService, UserService
# Swagger теги для группировки
AUTH_TAG = swagger_tag("Аутентификация", "authentication")
USER_TAG = swagger_tag("Пользователь", "user")
USER_ADMIN_TAG = swagger_tag("Управление пользователями", "user_management")
class RegisterView(APIView):
@@ -151,6 +155,129 @@ class CurrentUserView(APIView):
return Response(serializer.data)
class AdminUserListCreateView(APIView):
"""Список пользователей и создание пользователя администратором."""
permission_classes = [IsAdminUser]
@swagger_auto_schema(
tags=[USER_ADMIN_TAG],
operation_summary="Список пользователей",
operation_description="Возвращает список пользователей. Доступно только администраторам.",
responses={
200: UserSerializer(many=True),
**ErrorResponses.ADMIN,
},
)
def get(self, request):
serializer = UserSerializer(UserService.get_users_queryset(), many=True)
return Response(serializer.data)
@swagger_auto_schema(
tags=[USER_ADMIN_TAG],
operation_summary="Создать пользователя",
operation_description=(
"Создаёт пользователя и назначает ему одну из ролей: " "`user` или `admin`."
),
request_body=AdminUserCreateSerializer,
responses={
201: UserSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN,
},
)
def post(self, request):
serializer = AdminUserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = UserService.create_managed_user(**serializer.validated_data)
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED)
class AdminUserDetailView(APIView):
"""Просмотр и редактирование пользователя администратором."""
permission_classes = [IsAdminUser]
def _get_user(self, user_id):
return get_object_or_404(UserService.get_users_queryset(), id=user_id)
@swagger_auto_schema(
tags=[USER_ADMIN_TAG],
operation_summary="Детали пользователя",
operation_description="Возвращает данные конкретного пользователя.",
responses={
200: UserSerializer,
**ErrorResponses.ADMIN_NOT_FOUND,
},
)
def get(self, request, user_id: int):
user = self._get_user(user_id)
return Response(UserSerializer(user).data)
@swagger_auto_schema(
tags=[USER_ADMIN_TAG],
operation_summary="Обновить пользователя",
operation_description=(
"Частично обновляет пользователя. Администратор может изменить "
"роль, активность и базовые учётные данные."
),
request_body=AdminUserUpdateSerializer,
responses={
200: UserSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN_NOT_FOUND,
},
)
def patch(self, request, user_id: int):
user = self._get_user(user_id)
serializer = AdminUserUpdateSerializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
if request.user.id == user.id:
if serializer.validated_data.get("is_active") is False:
return Response(
{"detail": "Нельзя деактивировать самого себя."},
status=status.HTTP_400_BAD_REQUEST,
)
if serializer.validated_data.get("role") == UserService.ROLE_USER:
return Response(
{"detail": "Нельзя снять у себя роль администратора."},
status=status.HTTP_400_BAD_REQUEST,
)
updated_user = UserService.update_managed_user(
user_id=user.id,
**serializer.validated_data,
)
return Response(UserSerializer(updated_user).data)
class AdminUserDeactivateView(APIView):
"""Деактивация пользователя администратором."""
permission_classes = [IsAdminUser]
@swagger_auto_schema(
tags=[USER_ADMIN_TAG],
operation_summary="Деактивировать пользователя",
operation_description="Помечает пользователя как неактивного без удаления записи.",
responses={
200: UserSerializer,
400: CommonResponses.BAD_REQUEST,
**ErrorResponses.ADMIN_NOT_FOUND,
},
)
def post(self, request, user_id: int):
if request.user.id == user_id:
return Response(
{"detail": "Нельзя деактивировать самого себя."},
status=status.HTTP_400_BAD_REQUEST,
)
user = UserService.deactivate_user(user_id)
return Response(UserSerializer(user).data)
class UserUpdateView(APIView):
"""Обновление данных пользователя."""