feat: seed ролей, прав, матрицы §10 и Головного офиса, загрузчик scripts.seed

Двухфазный загрузчик: валидация yaml целиком до записи (SeedError), затем upsert по
натуральному ключу без удалений; run_seed(session, *, dev, seed_dir) без commit, CLI коммитит.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 21:07:07 +04:00
parent c8c218e4ac
commit 186ae802e6
10 changed files with 985 additions and 0 deletions

68
api/scripts/seed.py Normal file
View File

@ -0,0 +1,68 @@
"""Загрузчик справочников продукта: `python -m scripts.seed [--dev]` (запуск из `api/`).
Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(session, *, dev)` не делает commit — коммитит CLI.
Источник данных — `api/seed/core/*.yaml|csv` (+ `api/seed/dev/` при `--dev`, задача 15).
Загрузка двухфазная:
1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из
`src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается.
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
ключу, без удалений.
Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`.
"""
import argparse
import asyncio
import sys
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from scripts.seed_lib.apply import apply_seed
from scripts.seed_lib.loaders import DEFAULT_SEED_DIR, load_seed
from scripts.seed_lib.model import SeedData, SeedError, SeedReport
__all__ = ["DEFAULT_SEED_DIR", "SeedError", "SeedReport", "apply_seed", "load_seed", "main", "run_seed"]
async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport:
"""Загрузить и применить seed (решение #18 / R-5): валидация до записи, без commit.
`seed_dir` — каталог с `core/` (и `dev/`), по умолчанию `api/seed`; подменяется в тестах.
"""
return await apply_seed(session, load_seed(seed_dir, dev=dev))
async def _run_cli(data: SeedData) -> SeedReport:
# импорт здесь: настройки читаются из ENV_FILE только при реальном запуске,
# поэтому `--help` и ошибки валидации работают без env
from src.core.config import settings # noqa: PLC0415
engine = create_async_engine(
settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": "dkb"}}
)
try:
async with async_sessionmaker(engine, expire_on_commit=False)() as session, session.begin():
return await apply_seed(session, data)
finally:
await engine.dispose()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m scripts.seed", description="Загрузка справочников продукта.")
parser.add_argument("--dev", action="store_true", help="дополнительно загрузить dev-данные из seed/dev/")
parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)")
args = parser.parse_args(argv)
try:
data = load_seed(args.seed_dir, dev=args.dev)
report = asyncio.run(_run_cli(data))
except SeedError as error:
print(f"Ошибка seed: {error}", file=sys.stderr)
return 1
print(report.format())
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1 @@
"""Части загрузчика seed: модель набора, чтение/проверка файлов, применение к БД."""

View File

@ -0,0 +1,117 @@
"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed_lib.model import SeedData, SeedReport, TableStat
from src.models.access import Permission, Role, RolePermission
from src.models.directories import RegionalOffice
async def _upsert(
session: AsyncSession,
model: Any,
key: str,
rows: list[dict[str, Any]],
stat: TableStat,
) -> None:
"""Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет."""
if not rows:
return
key_column = getattr(model, key)
value_names = [name for name in rows[0] if name != key]
existing: dict[Any, tuple[Any, ...]] = {
row[0]: tuple(row[1:])
for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all()
}
changed = []
for row in rows:
current = existing.get(row[key])
if current is None:
stat.inserted += 1
changed.append(row)
elif current != tuple(row[name] for name in value_names):
stat.updated += 1
changed.append(row)
else:
stat.unchanged += 1
if changed:
statement = pg_insert(model)
statement = statement.on_conflict_do_update(
index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names}
)
await session.execute(statement, changed)
async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None:
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()}
existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all())
new_rows = []
for role_code, codes in data.role_permissions.items():
for code in codes:
pair = (role_ids[role_code], permission_ids[code])
if pair in existing:
stat.unchanged += 1
else:
stat.inserted += 1
new_rows.append(
{"role_id": pair[0], "permission_id": pair[1], "granted_by": None, "granted_at": func.now()}
)
if new_rows:
# .values(): func.now() допустим только во встроенном VALUES, не в executemany
statement = (
pg_insert(RolePermission)
.values(new_rows)
.on_conflict_do_nothing(index_elements=["role_id", "permission_id"])
)
await session.execute(statement)
async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
"""Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий."""
report = SeedReport()
await _upsert(
session,
RegionalOffice,
"name",
[{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices],
report.stat("regional_office"),
)
await _upsert(
session,
Role,
"code",
[
{
"code": r.code,
"name": r.name,
"description": r.description,
"is_system": r.is_system,
"is_active": r.is_active,
}
for r in data.roles
],
report.stat("role"),
)
await _upsert(
session,
Permission,
"code",
[
{
"code": p.code,
"object_type": p.object_type,
"action": p.action,
"name": p.name,
"description": p.description,
}
for p in data.permissions
],
report.stat("permission"),
)
await _apply_role_permissions(session, data, report.stat("role_permission"))
return report

View File

@ -0,0 +1,204 @@
"""Фаза 1: чтение файлов `seed/` в память и проверка (обязательные поля, enum'ы, ссылки внутри наборов).
База данных здесь не используется; любая ошибка — `SeedError` с файлом и записью.
"""
from pathlib import Path
from typing import Any
import yaml
from scripts.seed_lib.model import PermissionSeed, RegionalOfficeSeed, RoleSeed, SeedData, SeedError
from src.models.enums import PermissionAction, PermissionObject
DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
def _where(path: Path, seed_dir: Path, index: int | None = None, key: str | None = None) -> str:
place = str(path.relative_to(seed_dir)) if path.is_relative_to(seed_dir) else str(path)
if index is not None:
place += f", запись №{index + 1}"
if key is not None:
place += f" ({key})"
return place
def _read_yaml(path: Path, seed_dir: Path) -> Any:
if not path.is_file():
raise SeedError(f"{_where(path, seed_dir)}: файл не найден")
try:
return yaml.safe_load(path.read_text(encoding="utf-8"))
except (yaml.YAMLError, UnicodeDecodeError) as error:
raise SeedError(f"{_where(path, seed_dir)}: не удалось разобрать файл: {error}") from error
def _records(data: Any, allowed: set[str], required: set[str], path: Path, seed_dir: Path) -> list[dict[str, Any]]:
"""Список записей-словарей: тип, обязательные поля, отсутствие лишних ключей."""
if not isinstance(data, list):
raise SeedError(f"{_where(path, seed_dir)}: ожидается список записей")
for index, record in enumerate(data):
if not isinstance(record, dict):
raise SeedError(f"{_where(path, seed_dir, index)}: ожидается словарь полей")
for key in sorted(set(record) - allowed):
raise SeedError(f"{_where(path, seed_dir, index)}: неизвестное поле «{key}»")
for key in sorted(required - set(record)):
raise SeedError(f"{_where(path, seed_dir, index)}: нет обязательного поля «{key}»")
return data
def _str(record: dict[str, Any], key: str, where: str, *, optional: bool = False) -> str | None:
value = record.get(key)
if value is None and optional:
return None
if not isinstance(value, str) or not value.strip():
raise SeedError(f"{where}: поле «{key}» должно быть непустой строкой")
return value.strip()
def _bool(record: dict[str, Any], key: str, where: str, *, default: bool) -> bool:
value = record.get(key, default)
if not isinstance(value, bool):
raise SeedError(f"{where}: поле «{key}» должно быть true или false, получено {value!r}")
return value
def _unique(values: list[str], what: str, path: Path, seed_dir: Path) -> None:
seen: set[str] = set()
for value in values:
if value in seen:
raise SeedError(f"{_where(path, seed_dir)}: {what} «{value}» встречается больше одного раза")
seen.add(value)
def _load_regional_offices(core: Path, seed_dir: Path) -> list[RegionalOfficeSeed]:
path = core / "regional_office.yaml"
records = _records(_read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir)
result = []
for index, record in enumerate(records):
where = _where(path, seed_dir, index)
result.append(
RegionalOfficeSeed(
name=str(_str(record, "name", where)),
is_head_office=_bool(record, "is_head_office", where, default=False),
is_active=_bool(record, "is_active", where, default=True),
)
)
_unique([o.name for o in result], "филиал", path, seed_dir)
return result
def _load_roles(core: Path, seed_dir: Path) -> list[RoleSeed]:
path = core / "roles.yaml"
records = _records(
_read_yaml(path, seed_dir),
{"code", "name", "description", "is_system", "is_active"},
{"code", "name"},
path,
seed_dir,
)
result = []
for index, record in enumerate(records):
where = _where(path, seed_dir, index, str(record.get("code")))
result.append(
RoleSeed(
code=str(_str(record, "code", where)),
name=str(_str(record, "name", where)),
description=_str(record, "description", where, optional=True),
is_system=_bool(record, "is_system", where, default=False),
is_active=_bool(record, "is_active", where, default=True),
)
)
_unique([r.code for r in result], "роль", path, seed_dir)
return result
def _expected_permission_code(object_type: PermissionObject, action: PermissionAction) -> str:
"""Код права `<OBJECT>_<ACTION>`; при совпадении объекта и действия — одно слово (`EXPORT`)."""
if object_type.value == action.value:
return action.value
return f"{object_type.value}_{action.value}"
def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]:
path = core / "permissions.yaml"
records = _records(
_read_yaml(path, seed_dir),
{"code", "object", "action", "name", "description"},
{"code", "object", "action", "name"},
path,
seed_dir,
)
result = []
for index, record in enumerate(records):
where = _where(path, seed_dir, index, str(record.get("code")))
try:
object_type = PermissionObject(record["object"])
except ValueError:
raise SeedError(f"{where}: неизвестный объект права «{record['object']}»") from None
try:
action = PermissionAction(record["action"])
except ValueError:
raise SeedError(f"{where}: неизвестное действие права «{record['action']}»") from None
code = str(_str(record, "code", where))
if code != _expected_permission_code(object_type, action):
raise SeedError(
f"{where}: код права «{code}» не соответствует объекту и действию, "
f"ожидался «{_expected_permission_code(object_type, action)}»"
)
result.append(
PermissionSeed(
code=code,
object_type=object_type,
action=action,
name=str(_str(record, "name", where)),
description=_str(record, "description", where, optional=True),
)
)
_unique([p.code for p in result], "право", path, seed_dir)
pairs = [f"{p.object_type.value}.{p.action.value}" for p in result]
_unique(pairs, "сочетание объект.действие", path, seed_dir)
return result
def _load_role_permissions(
core: Path, seed_dir: Path, role_codes: set[str], permission_codes: set[str]
) -> dict[str, list[str]]:
path = core / "role_permissions.yaml"
data = _read_yaml(path, seed_dir)
if not isinstance(data, dict):
raise SeedError(f"{_where(path, seed_dir)}: ожидается словарь «код роли -> список кодов прав»")
result: dict[str, list[str]] = {}
for role_code, codes in data.items():
where = _where(path, seed_dir, key=str(role_code))
if role_code not in role_codes:
raise SeedError(f"{where}: роль «{role_code}» не описана в roles.yaml")
if not isinstance(codes, list) or not all(isinstance(c, str) for c in codes):
raise SeedError(f"{where}: ожидается список кодов прав")
for code in codes:
if code not in permission_codes:
raise SeedError(f"{where}: право «{code}» не описано в permissions.yaml")
duplicates = sorted({c for c in codes if codes.count(c) > 1})
if duplicates:
raise SeedError(f"{where}: право «{duplicates[0]}» указано больше одного раза")
result[role_code] = list(codes)
return result
def load_seed(seed_dir: Path | None = None, *, dev: bool = False) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается."""
seed_dir = seed_dir or DEFAULT_SEED_DIR
core = seed_dir / "core"
data = SeedData()
data.roles = _load_roles(core, seed_dir)
data.permissions = _load_permissions(core, seed_dir)
data.role_permissions = _load_role_permissions(
core, seed_dir, {r.code for r in data.roles}, {p.code for p in data.permissions}
)
data.regional_offices = _load_regional_offices(core, seed_dir)
if dev:
_load_dev(data, seed_dir / "dev")
return data
def _load_dev(data: SeedData, dev_dir: Path) -> None:
"""Заглушка расширения: dev-данные (филиалы, пользователи по ролям) появятся в задаче 15."""

View File

@ -0,0 +1,69 @@
"""Модель проверенного набора seed и отчёта о применении (без доступа к БД)."""
from dataclasses import dataclass, field
from src.models.enums import PermissionAction, PermissionObject
class SeedError(Exception):
"""Ошибка файлов seed (отсутствие, формат, ссылочная целостность); сообщение — по-русски, с файлом и записью."""
@dataclass(frozen=True)
class RegionalOfficeSeed:
name: str
is_head_office: bool
is_active: bool
@dataclass(frozen=True)
class RoleSeed:
code: str
name: str
description: str | None
is_system: bool
is_active: bool
@dataclass(frozen=True)
class PermissionSeed:
code: str
object_type: PermissionObject
action: PermissionAction
name: str
description: str | None
@dataclass
class SeedData:
"""Проверенный набор seed; ничего не знает о БД."""
regional_offices: list[RegionalOfficeSeed] = field(default_factory=list)
roles: list[RoleSeed] = field(default_factory=list)
permissions: list[PermissionSeed] = field(default_factory=list)
#: код роли -> коды прав (порядок как в файле)
role_permissions: dict[str, list[str]] = field(default_factory=dict)
@dataclass
class TableStat:
inserted: int = 0
updated: int = 0
unchanged: int = 0
@dataclass
class SeedReport:
"""Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось."""
tables: dict[str, TableStat] = field(default_factory=dict)
def stat(self, table: str) -> TableStat:
return self.tables.setdefault(table, TableStat())
def format(self) -> str:
lines = ["Seed применён (вставлено / обновлено / без изменений):"]
lines += [
f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" for name, stat in self.tables.items()
]
return "\n".join(lines)

View File

@ -0,0 +1,67 @@
# Справочник прав «объект × действие» (docs/context/05-domain-rules.md §10; DBML permission_object / permission_action).
# Код права — <OBJECT>_<ACTION>; при совпадении объекта и действия (EXPORT) — одно слово EXPORT.
# Натуральный ключ upsert — code. Администратор новые коды не создаёт.
- code: DIRECTORY_VIEW
object: DIRECTORY
action: VIEW
name: "Справочники: просмотр"
- code: DIRECTORY_EDIT
object: DIRECTORY
action: EDIT
name: "Справочники: редактирование"
- code: DIRECTORY_DELETE
object: DIRECTORY
action: DELETE
name: "Справочники: удаление"
- code: DIRECTORY_ADMIN
object: DIRECTORY
action: ADMIN
name: "Справочники: администрирование (импорт)"
- code: DOCUMENT_VIEW
object: DOCUMENT
action: VIEW
name: "Документы: просмотр"
- code: DOCUMENT_EDIT
object: DOCUMENT
action: EDIT
name: "Документы: загрузка, правка, подтверждение"
- code: CONSOLIDATION_VIEW
object: CONSOLIDATION
action: VIEW
name: "Консолидация: просмотр"
- code: CONSOLIDATION_EDIT
object: CONSOLIDATION
action: EDIT
name: "Консолидация: редактирование"
- code: KFO_VIEW
object: KFO
action: VIEW
name: "КФО: просмотр"
- code: KFO_EXPORT
object: KFO
action: EXPORT
name: "КФО: выгрузка"
- code: KFO_APPROVE
object: KFO
action: APPROVE
name: "КФО: утверждение в РФ"
- code: KFO_VERIFY
object: KFO
action: VERIFY
name: "КФО: верификация в ГО"
- code: EX_VIEW
object: EX
action: VIEW
name: "Единое хранилище: просмотр"
- code: EXPORT
object: EXPORT
action: EXPORT
name: "Выгрузка отчётности"
- code: USERS_ADMIN
object: USERS
action: ADMIN
name: "Пользователи и роли: администрирование"
- code: AUDIT_VIEW
object: AUDIT
action: VIEW
name: "Журнал действий: просмотр"

View File

@ -0,0 +1,6 @@
# Филиалы продукта (spec reference-data-seed). DECISION-3 DBML: Головной офис — явная запись.
# Региональные филиалы — только dev-данные (seed/dev/), в прод-режиме загружается лишь ГО.
# Натуральный ключ upsert — name.
- name: Головной офис
is_head_office: true
is_active: true

View File

@ -0,0 +1,65 @@
# Матрица «роль → права»: docs/context/05-domain-rules.md §10 (ТЗ 8.1, Прил. 2 «Роль: перечень полномочий»).
# ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI.
# Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия
# policy-слоя, а не отдельные права, поэтому здесь не представлены.
# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право);
# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает.
ADMIN:
- DIRECTORY_VIEW
- DIRECTORY_EDIT
- DIRECTORY_DELETE
- DIRECTORY_ADMIN
- EX_VIEW
- USERS_ADMIN
- AUDIT_VIEW
CURATOR_GO:
- DIRECTORY_VIEW
- DIRECTORY_EDIT
- CONSOLIDATION_VIEW
- KFO_VIEW
- EX_VIEW
KA_RF:
- DIRECTORY_VIEW
- DIRECTORY_EDIT
- DOCUMENT_VIEW
- DOCUMENT_EDIT
- CONSOLIDATION_VIEW
- CONSOLIDATION_EDIT
- KFO_VIEW
- KFO_EXPORT
- EX_VIEW
- EXPORT
RKA:
- DIRECTORY_VIEW
- DIRECTORY_EDIT
- DOCUMENT_VIEW
- CONSOLIDATION_VIEW
- CONSOLIDATION_EDIT
- KFO_VIEW
- KFO_APPROVE
- EX_VIEW
- EXPORT
KA_GO:
- DIRECTORY_VIEW
- DIRECTORY_EDIT
- DOCUMENT_VIEW
- DOCUMENT_EDIT
- CONSOLIDATION_VIEW
- CONSOLIDATION_EDIT
- KFO_VIEW
- KFO_VERIFY
- KFO_EXPORT
- EX_VIEW
- EXPORT
EXPERT:
- DIRECTORY_VIEW
- DOCUMENT_VIEW
- CONSOLIDATION_VIEW
- KFO_VIEW
- EX_VIEW
CLIENT_MANAGER:
- DIRECTORY_VIEW
- DOCUMENT_VIEW
- DOCUMENT_EDIT
- CONSOLIDATION_VIEW
- KFO_VIEW

50
api/seed/core/roles.yaml Normal file
View File

@ -0,0 +1,50 @@
# Системные роли ТЗ 8.1 (docs/context/05-domain-rules.md §10; ТЗ «Ролевая модель»). Натуральный ключ upsert — code.
# Название и описание можно править в файле: повторный запуск seed обновит строку role, а не создаст новую.
# Удаление ролей seed не выполняет.
- code: KA_RF
name: Кредитный аналитик РФ
description: >-
Инициация КФО, загрузка и правка форм ФО по компаниям ГСК, редактирование сводной ОСВ, отправка КФО на
верификацию РКА РФ, выгрузка КФО; в своей зоне РФ.
is_system: true
is_active: true
- code: KA_GO
name: Кредитный аналитик ГО
description: >-
Все функции КА РФ без ограничения по филиалу, самостоятельная инициация КФО на уровне ГО, проверки и сравнение
КФО, верификация (№ и дата УОБ) на этапе «Консолидация ГО».
is_system: true
is_active: true
- code: RKA
name: Руководитель кредитного аналитика РФ
description: >-
Проверка и подтверждение КФО на этапе «Верификация РФ», передача на «Консолидация ГО», возврат на доработку КА РФ,
корректировка КФО (ОСВ), просмотр КФО по закреплённым ГК.
is_system: true
is_active: true
- code: CLIENT_MANAGER
name: Клиентский менеджер
description: >-
Просмотр статуса формирования КФО по своим клиентам и загрузка ФО по компаниям закреплённой ГК. В процессе
формирования КФО не участвует.
is_system: true
is_active: true
- code: CURATOR_GO
name: Куратор КП ГО
description: >-
Создание ГК, перезакрепление ГК за пользователями РФ, сверка состава периметра. В процессе формирования КФО не
участвует.
is_system: true
is_active: true
- code: EXPERT
name: Эксперт
description: Просмотр данных ФО и КФО по всем ГК (только чтение).
is_system: true
is_active: true
- code: ADMIN
name: Администратор
description: >-
Управление пользователями, ролями и правами доступа, ведение справочников. Отчёты не формирует, не редактирует и
не утверждает.
is_system: true
is_active: true

View File

@ -0,0 +1,338 @@
"""Seed ролей, прав, матрицы §10 и ГО (spec reference-data-seed, задача 12).
Матрица §10 переписана в тесте заново и независимо от `seed/core/role_permissions.yaml`.
"""
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import SeedError, SeedReport, load_seed, run_seed
from src.models.access import AppUser, Permission, Role, RolePermission
from src.models.directories import RegionalOffice
API_DIR = Path(__file__).resolve().parent.parent
SEED_DIR = API_DIR / "seed"
# docs/context/05-domain-rules.md §10: роль -> множество (объект, действие). Пояснения в скобках — policy, не права.
MATRIX_S10: dict[str, set[tuple[str, str]]] = {
"ADMIN": {
("DIRECTORY", "VIEW"),
("DIRECTORY", "EDIT"),
("DIRECTORY", "DELETE"),
("DIRECTORY", "ADMIN"),
("EX", "VIEW"),
("USERS", "ADMIN"),
("AUDIT", "VIEW"),
},
"CURATOR_GO": {
("DIRECTORY", "VIEW"),
("DIRECTORY", "EDIT"),
("CONSOLIDATION", "VIEW"),
("KFO", "VIEW"),
("EX", "VIEW"),
},
"KA_RF": {
("DIRECTORY", "VIEW"),
("DIRECTORY", "EDIT"),
("DOCUMENT", "VIEW"),
("DOCUMENT", "EDIT"),
("CONSOLIDATION", "VIEW"),
("CONSOLIDATION", "EDIT"),
("KFO", "VIEW"),
("KFO", "EXPORT"),
("EX", "VIEW"),
("EXPORT", "EXPORT"),
},
"RKA": {
("DIRECTORY", "VIEW"),
("DIRECTORY", "EDIT"),
("DOCUMENT", "VIEW"),
("CONSOLIDATION", "VIEW"),
("CONSOLIDATION", "EDIT"),
("KFO", "VIEW"),
("KFO", "APPROVE"),
("EX", "VIEW"),
("EXPORT", "EXPORT"),
},
"KA_GO": {
("DIRECTORY", "VIEW"),
("DIRECTORY", "EDIT"),
("DOCUMENT", "VIEW"),
("DOCUMENT", "EDIT"),
("CONSOLIDATION", "VIEW"),
("CONSOLIDATION", "EDIT"),
("KFO", "VIEW"),
("KFO", "VERIFY"),
("KFO", "EXPORT"),
("EX", "VIEW"),
("EXPORT", "EXPORT"),
},
"EXPERT": {
("DIRECTORY", "VIEW"),
("DOCUMENT", "VIEW"),
("CONSOLIDATION", "VIEW"),
("KFO", "VIEW"),
("EX", "VIEW"),
},
"CLIENT_MANAGER": {
("DIRECTORY", "VIEW"),
("DOCUMENT", "VIEW"),
("DOCUMENT", "EDIT"),
("CONSOLIDATION", "VIEW"),
("KFO", "VIEW"),
},
}
ROLE_COUNT = 7
PERMISSION_COUNT = 16
GRANT_COUNT = sum(len(pairs) for pairs in MATRIX_S10.values()) # 52
ROLE_CODES = {"KA_RF", "KA_GO", "RKA", "CLIENT_MANAGER", "CURATOR_GO", "EXPERT", "ADMIN"}
async def _count(session: AsyncSession, model: type) -> int:
return int((await session.execute(select(func.count()).select_from(model))).scalar_one())
async def _counts(session: AsyncSession) -> dict[str, int]:
return {
"role": await _count(session, Role),
"permission": await _count(session, Permission),
"role_permission": await _count(session, RolePermission),
"regional_office": await _count(session, RegionalOffice),
}
def _copy_seed(tmp_path: Path) -> Path:
target = tmp_path / "seed"
shutil.copytree(SEED_DIR, target)
return target
@pytest.mark.integration
async def test_first_run_loads_roles_permissions_and_head_office(db_session: AsyncSession) -> None:
report = await run_seed(db_session)
counts = await _counts(db_session)
assert counts["role"] == ROLE_COUNT
assert counts["permission"] == PERMISSION_COUNT
assert counts["role_permission"] == GRANT_COUNT
assert counts["regional_office"] == 1
roles = (await db_session.execute(select(Role))).scalars().all()
assert {r.code for r in roles} == ROLE_CODES
assert all(r.is_system and r.is_active and r.name for r in roles)
head = (await db_session.execute(select(RegionalOffice))).scalar_one()
assert head.name == "Головной офис"
assert head.is_head_office is True
assert isinstance(report, SeedReport)
assert report.tables["role"].inserted == ROLE_COUNT
assert report.tables["role_permission"].inserted == GRANT_COUNT
@pytest.mark.integration
async def test_second_run_does_not_change_row_counts(db_session: AsyncSession) -> None:
await run_seed(db_session)
first = await _counts(db_session)
report = await run_seed(db_session)
assert await _counts(db_session) == first
assert all(stat.inserted == 0 and stat.updated == 0 for stat in report.tables.values())
@pytest.mark.integration
async def test_role_permission_matches_matrix_s10(db_session: AsyncSession) -> None:
await run_seed(db_session)
rows = (
await db_session.execute(
select(Role.code, Permission.object_type, Permission.action)
.join(RolePermission, RolePermission.role_id == Role.id)
.join(Permission, Permission.id == RolePermission.permission_id)
)
).all()
actual: dict[str, set[tuple[str, str]]] = {}
for code, obj, action in rows:
actual.setdefault(code, set()).add((obj.value, action.value))
assert set(actual) == set(MATRIX_S10)
for role, expected in MATRIX_S10.items():
assert actual[role] == expected, role
@pytest.mark.integration
async def test_permission_codes_and_grant_metadata(db_session: AsyncSession) -> None:
await run_seed(db_session)
perms = (await db_session.execute(select(Permission))).scalars().all()
for perm in perms:
expected = (
perm.action.value
if perm.object_type.value == perm.action.value
else (f"{perm.object_type.value}_{perm.action.value}")
)
assert perm.code == expected
assert "EXPORT" in {p.code for p in perms}
grants = (await db_session.execute(select(RolePermission))).scalars().all()
assert all(g.granted_by is None and g.granted_at is not None for g in grants)
@pytest.mark.integration
async def test_role_name_change_updates_row_instead_of_inserting(db_session: AsyncSession, tmp_path: Path) -> None:
seed_dir = _copy_seed(tmp_path)
await run_seed(db_session, seed_dir=seed_dir)
roles_path = seed_dir / "core" / "roles.yaml"
roles = yaml.safe_load(roles_path.read_text(encoding="utf-8"))
for role in roles:
if role["code"] == "EXPERT":
role["name"] = "Эксперт (переименован)"
roles_path.write_text(yaml.safe_dump(roles, allow_unicode=True), encoding="utf-8")
report = await run_seed(db_session, seed_dir=seed_dir)
assert await _count(db_session, Role) == ROLE_COUNT
expert = (await db_session.execute(select(Role).where(Role.code == "EXPERT"))).scalar_one()
assert expert.name == "Эксперт (переименован)"
assert report.tables["role"].updated == 1
assert report.tables["role"].inserted == 0
@pytest.mark.integration
async def test_unknown_permission_in_matrix_fails_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None:
seed_dir = _copy_seed(tmp_path)
matrix_path = seed_dir / "core" / "role_permissions.yaml"
matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8"))
matrix["EXPERT"].append("NO_SUCH_PERMISSION")
matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8")
before = await _counts(db_session)
with pytest.raises(SeedError, match="NO_SUCH_PERMISSION"):
await run_seed(db_session, seed_dir=seed_dir)
assert (
await _counts(db_session) == before == {"role": 0, "permission": 0, "role_permission": 0, "regional_office": 0}
)
@pytest.mark.integration
async def test_unknown_role_in_matrix_fails_before_any_write(db_session: AsyncSession, tmp_path: Path) -> None:
seed_dir = _copy_seed(tmp_path)
matrix_path = seed_dir / "core" / "role_permissions.yaml"
matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8"))
matrix["GHOST_ROLE"] = ["EX_VIEW"]
matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8")
with pytest.raises(SeedError, match="GHOST_ROLE"):
await run_seed(db_session, seed_dir=seed_dir)
assert await _count(db_session, Role) == 0
@pytest.mark.integration
async def test_without_dev_flag_no_users_are_loaded(db_session: AsyncSession) -> None:
await run_seed(db_session)
assert await _count(db_session, AppUser) == 0
assert await _count(db_session, RegionalOffice) == 1
@pytest.mark.integration
async def test_dev_flag_is_accepted(db_session: AsyncSession) -> None:
"""Каркас --dev: до задачи 15 dev-данных нет, но флаг не должен падать."""
await run_seed(db_session, dev=True)
assert await _count(db_session, Role) == ROLE_COUNT
# ---- валидатор без БД ----
def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def test_loader_reads_repository_seed() -> None:
data = load_seed(SEED_DIR)
assert len(data.roles) == ROLE_COUNT
assert len(data.permissions) == PERMISSION_COUNT
assert len(data.regional_offices) == 1
assert sum(len(codes) for codes in data.role_permissions.values()) == GRANT_COUNT
@pytest.mark.parametrize(
("file", "mutate", "expected"),
[
(
"roles.yaml",
lambda text: text.replace("code: KA_RF\n", "code: KA_RF\n unknown_field: 1\n", 1),
"unknown_field",
),
("roles.yaml", lambda text: text.replace("is_system: true", "is_system: yes-please", 1), "is_system"),
("permissions.yaml", lambda text: text.replace("action: VIEW", "action: LOOK", 1), "LOOK"),
("permissions.yaml", lambda text: text.replace("code: DIRECTORY_VIEW", "code: WRONG_CODE", 1), "WRONG_CODE"),
("roles.yaml", lambda text: text.replace("code: ADMIN", "code: KA_RF", 1), "KA_RF"),
("regional_office.yaml", lambda text: text.replace("- name: Головной офис\n ", "- ", 1), "name"),
("roles.yaml", lambda text: "- [broken", "roles.yaml"),
],
)
def test_validator_rejects_malformed_seed(
tmp_path: Path,
file: str,
mutate,
expected: str, # noqa: ANN001
) -> None:
seed_dir = _copy_seed(tmp_path)
path = seed_dir / "core" / file
path.write_text(mutate(path.read_text(encoding="utf-8")), encoding="utf-8")
with pytest.raises(SeedError, match=expected):
load_seed(seed_dir)
def test_validator_rejects_duplicate_permission_in_role(tmp_path: Path) -> None:
seed_dir = _copy_seed(tmp_path)
path = seed_dir / "core" / "role_permissions.yaml"
path.write_text(path.read_text(encoding="utf-8").replace("ADMIN:\n", "ADMIN:\n - EX_VIEW\n", 1), encoding="utf-8")
with pytest.raises(SeedError, match="EX_VIEW"):
load_seed(seed_dir)
def test_missing_seed_file_is_seed_error(tmp_path: Path) -> None:
with pytest.raises(SeedError, match="roles.yaml"):
load_seed(tmp_path)
def test_cli_validation_error_exits_with_code_1_without_db(tmp_path: Path) -> None:
"""CLI с битым каталогом seed: код 1 и сообщение в stderr; до БД дело не доходит."""
result = subprocess.run( # noqa: S603
[sys.executable, "-m", "scripts.seed", "--seed-dir", str(tmp_path)],
cwd=API_DIR,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "roles.yaml" in result.stderr
def test_cli_help() -> None:
result = subprocess.run( # noqa: S603
[sys.executable, "-m", "scripts.seed", "--help"], cwd=API_DIR, capture_output=True, text=True, check=False
)
assert result.returncode == 0
assert "--dev" in result.stdout