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:
parent
c8c218e4ac
commit
186ae802e6
68
api/scripts/seed.py
Normal file
68
api/scripts/seed.py
Normal 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())
|
||||
1
api/scripts/seed_lib/__init__.py
Normal file
1
api/scripts/seed_lib/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Части загрузчика seed: модель набора, чтение/проверка файлов, применение к БД."""
|
||||
117
api/scripts/seed_lib/apply.py
Normal file
117
api/scripts/seed_lib/apply.py
Normal 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
|
||||
204
api/scripts/seed_lib/loaders.py
Normal file
204
api/scripts/seed_lib/loaders.py
Normal 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."""
|
||||
69
api/scripts/seed_lib/model.py
Normal file
69
api/scripts/seed_lib/model.py
Normal 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)
|
||||
67
api/seed/core/permissions.yaml
Normal file
67
api/seed/core/permissions.yaml
Normal 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: "Журнал действий: просмотр"
|
||||
6
api/seed/core/regional_office.yaml
Normal file
6
api/seed/core/regional_office.yaml
Normal 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
|
||||
65
api/seed/core/role_permissions.yaml
Normal file
65
api/seed/core/role_permissions.yaml
Normal 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
50
api/seed/core/roles.yaml
Normal 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
|
||||
338
api/tests/test_seed_roles.py
Normal file
338
api/tests/test_seed_roles.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user