- OsvDataType.NU после BU, DBML_VERSION = 5.15, Makefile/dbml_parity/docstring'и на data-model-v5.15.dbml; - миграция 0003_dbml_v5_15: ADD VALUE 'NU' AFTER 'BU' + lower(app_user.email); downgrade пересоздаёт osv_data_type (регистр email не возвращается); - email хранится в нижнем регистре: users_admin.create_user и seed (_apply_users); с UNIQUE по строчному email гонка создания в другом регистре теперь ловится индексом (тест test_api_constraint_races обновлён); - тесты: enum NU, откат на шаг, lower email на миграции, seed/админка, вставка NU, фиктивная v5.16. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""Схемы API администрирования `/admin/*` (spec users-admin, design D-8): пользователи, роли, матрица прав.
|
||
|
||
Поля — как в DBML v5.15 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом
|
||
входе через РАИСА), поэтому тела запросов запрещают лишние поля.
|
||
"""
|
||
|
||
import datetime
|
||
import uuid
|
||
from typing import Self
|
||
|
||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||
|
||
from src.models.enums import BusinessSegment, PermissionAction, PermissionObject
|
||
from src.schemas.directories import NAME_MAX_LENGTH, RegionalOfficeOut
|
||
|
||
# Поля `app_user`, которые не могут быть NULL: явный `null` в PATCH — ошибка валидации, а не «очистить»
|
||
_NOT_NULL_UPDATE_FIELDS = ("full_name", "regional_office_id", "is_active")
|
||
|
||
|
||
def _strip_full_name(value: str) -> str:
|
||
stripped = value.strip()
|
||
if not stripped:
|
||
raise ValueError("ФИО не может быть пустым")
|
||
return stripped
|
||
|
||
|
||
def _blank_to_none(value: str | None) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return value.strip() or None
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
"""Создание пользователя: email уникален без учёта регистра (проверяет сервис), роли — коды активных ролей."""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
email: EmailStr
|
||
full_name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
||
regional_office_id: uuid.UUID
|
||
employee_no: str | None = None
|
||
business_segment: BusinessSegment | None = None
|
||
roles: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("full_name")
|
||
@classmethod
|
||
def _full_name(cls, value: str) -> str:
|
||
return _strip_full_name(value)
|
||
|
||
@field_validator("employee_no")
|
||
@classmethod
|
||
def _employee_no(cls, value: str | None) -> str | None:
|
||
return _blank_to_none(value)
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
"""Изменение пользователя; «поле передано» — по `model_fields_set`.
|
||
|
||
`employee_no` и `business_segment` можно очистить (`null`); `full_name`, `regional_office_id`, `is_active` — нет.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
full_name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
||
employee_no: str | None = None
|
||
regional_office_id: uuid.UUID | None = None
|
||
business_segment: BusinessSegment | None = None
|
||
is_active: bool | None = None
|
||
|
||
@field_validator("full_name")
|
||
@classmethod
|
||
def _full_name(cls, value: str | None) -> str | None:
|
||
return None if value is None else _strip_full_name(value)
|
||
|
||
@field_validator("employee_no")
|
||
@classmethod
|
||
def _employee_no(cls, value: str | None) -> str | None:
|
||
return _blank_to_none(value)
|
||
|
||
@model_validator(mode="after")
|
||
def _no_null_for_required(self) -> Self:
|
||
nulls = [
|
||
name for name in _NOT_NULL_UPDATE_FIELDS if name in self.model_fields_set and getattr(self, name) is None
|
||
]
|
||
if nulls:
|
||
raise ValueError(f"поля не могут быть null: {', '.join(nulls)}")
|
||
return self
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
"""Пользователь для администратора: филиал целиком и коды назначенных ролей (по алфавиту)."""
|
||
|
||
model_config = ConfigDict(use_enum_values=True)
|
||
|
||
id: uuid.UUID
|
||
email: str | None
|
||
full_name: str
|
||
employee_no: str | None
|
||
regional_office: RegionalOfficeOut
|
||
business_segment: BusinessSegment | None
|
||
is_active: bool
|
||
roles: list[str]
|
||
created_at: datetime.datetime | None
|
||
updated_at: datetime.datetime | None
|
||
|
||
|
||
class UserListQuery(BaseModel):
|
||
"""Фильтры списка пользователей: `q` — подстрока email или ФИО без учёта регистра, `is_active`."""
|
||
|
||
q: str | None = None
|
||
is_active: bool | None = None
|
||
|
||
|
||
class RolesSet(BaseModel):
|
||
"""Новый набор ролей пользователя (коды); заменяет прежний целиком."""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
roles: list[str]
|
||
|
||
|
||
class RoleOut(BaseModel):
|
||
"""Роль с кодами её прав (по алфавиту)."""
|
||
|
||
code: str
|
||
name: str
|
||
description: str | None
|
||
is_system: bool
|
||
is_active: bool
|
||
permissions: list[str]
|
||
|
||
|
||
class PermissionOut(BaseModel):
|
||
"""Право из справочника (только чтение: новые коды не создаются)."""
|
||
|
||
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
|
||
|
||
code: str
|
||
object_type: PermissionObject
|
||
action: PermissionAction
|
||
name: str
|
||
description: str | None
|
||
|
||
|
||
class PermissionsSet(BaseModel):
|
||
"""Новый набор прав роли (коды); заменяет прежний целиком."""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
permissions: list[str]
|