arm-core/api/src/schemas/admin.py
Andrey Epifancev ab606dc59a feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003
- 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
2026-10-05 21:28:04 +04:00

151 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Схемы 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]