diff --git a/api/scripts/seed.py b/api/scripts/seed.py new file mode 100644 index 0000000..94ca722 --- /dev/null +++ b/api/scripts/seed.py @@ -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()) diff --git a/api/scripts/seed_lib/__init__.py b/api/scripts/seed_lib/__init__.py new file mode 100644 index 0000000..55e9bb0 --- /dev/null +++ b/api/scripts/seed_lib/__init__.py @@ -0,0 +1 @@ +"""Части загрузчика seed: модель набора, чтение/проверка файлов, применение к БД.""" diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py new file mode 100644 index 0000000..ea3e748 --- /dev/null +++ b/api/scripts/seed_lib/apply.py @@ -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 diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py new file mode 100644 index 0000000..7ed5be1 --- /dev/null +++ b/api/scripts/seed_lib/loaders.py @@ -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: + """Код права `_`; при совпадении объекта и действия — одно слово (`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.""" diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py new file mode 100644 index 0000000..34f28f0 --- /dev/null +++ b/api/scripts/seed_lib/model.py @@ -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) diff --git a/api/seed/core/permissions.yaml b/api/seed/core/permissions.yaml new file mode 100644 index 0000000..34b518a --- /dev/null +++ b/api/seed/core/permissions.yaml @@ -0,0 +1,67 @@ +# Справочник прав «объект × действие» (docs/context/05-domain-rules.md §10; DBML permission_object / permission_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: "Журнал действий: просмотр" diff --git a/api/seed/core/regional_office.yaml b/api/seed/core/regional_office.yaml new file mode 100644 index 0000000..ff442b3 --- /dev/null +++ b/api/seed/core/regional_office.yaml @@ -0,0 +1,6 @@ +# Филиалы продукта (spec reference-data-seed). DECISION-3 DBML: Головной офис — явная запись. +# Региональные филиалы — только dev-данные (seed/dev/), в прод-режиме загружается лишь ГО. +# Натуральный ключ upsert — name. +- name: Головной офис + is_head_office: true + is_active: true diff --git a/api/seed/core/role_permissions.yaml b/api/seed/core/role_permissions.yaml new file mode 100644 index 0000000..0684198 --- /dev/null +++ b/api/seed/core/role_permissions.yaml @@ -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 diff --git a/api/seed/core/roles.yaml b/api/seed/core/roles.yaml new file mode 100644 index 0000000..35b413c --- /dev/null +++ b/api/seed/core/roles.yaml @@ -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 diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py new file mode 100644 index 0000000..79deda4 --- /dev/null +++ b/api/tests/test_seed_roles.py @@ -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