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

@@ -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:
"""Сервисный слой для работы с профилями"""