From 2c830055644f282c047a5fb12281eb90cae68cdf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:47:42 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20seed=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=91?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=20=D0=B2=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D1=83=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20R-3,=20R?= =?UTF-8?q?-9,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При непустой `role_permission` вставляются пары, у которых роль или право вставлены этим же прогоном (`_upsert` возвращает вставленные ключи): их администратор снять не мог. Прочие пары по-прежнему не трогаются — снятые права не возвращаются. Убрана мёртвая ветка `unchanged` (R-9): при пустой таблице совпадений быть не может, при заполненной сверка не нужна. Тесты: новое право и новая роль во временном seed-каталоге → повторный seed выдаёт только их пары, снятая связь не возвращается; администратор снял все связи → матрица заливается заново. README, CLAUDE.md, комментарий `role_permissions.yaml` и докстринг `seed.py` уточнены. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 4 +- README.md | 17 ++++--- api/scripts/seed.py | 5 ++- api/scripts/seed_lib/apply.py | 69 ++++++++++++++++++----------- api/scripts/seed_lib/model.py | 16 ++++--- api/seed/core/role_permissions.yaml | 5 ++- api/tests/test_seed_roles.py | 58 +++++++++++++++++++++++- 7 files changed, 127 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1da6884..3dbc7f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,8 @@ dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`. Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта), `LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`). -- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — только в - пустую таблицу, иначе шаг пропускается); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`). +- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в + пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`). **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`). ## Зависимости от соседей diff --git a/README.md b/README.md index 5b43b1d..232b82f 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ make seed-dev # справочники продукта + dev-пользов `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав -`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см. -«Администрирование»). `make seed-dev` +`role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав +(правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev` (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 («`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. @@ -365,11 +365,14 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. - **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml` - `make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если - связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`: - право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`, - `permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде - (например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`). + `make seed` / `make seed-dev` загружают целиком только в **пустую** `role_permission` (первый запуск на новой БД). + Если связи уже есть, seed добавляет лишь пары, у которых роль **или** право впервые созданы этим же запуском + (новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте + `role_permission: пропущено — матрица прав уже заполнена…` (или число добавленных пар новых ролей и прав). Право, + снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары + существующих ролей и прав из файла выдаёт администратор (`PUT /admin/roles/{code}/permissions`). Роли и права + (`role`, `permission`) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял **все** + связи, таблица пуста, и следующий seed зальёт матрицу файла заново. - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново. - **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` diff --git a/api/scripts/seed.py b/api/scripts/seed.py index d6aeb49..4134ada 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -7,8 +7,9 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному - ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг - пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7). + ключу, без удалений. Исключение — матрица `role_permission`: целиком загружается только в пустую таблицу; в + заполненную — лишь пары ролей и прав, вставленных этим же запуском, остальное пропускается (в отчёте — + «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7, ревью R-3). План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46, diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index a69d95b..43385f1 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -20,10 +20,13 @@ async def _upsert( key: str, rows: list[dict[str, Any]], stat: TableStat, -) -> None: - """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.""" +) -> set[Any]: + """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет. + + Возвращает ключи строк, ВСТАВЛЕННЫХ этим прогоном (для матрицы прав: связи новых ролей и прав). + """ if not rows: - return + return set() key_column = getattr(model, key) value_names = [name for name in rows[0] if name != key] existing: dict[Any, tuple[Any, ...]] = { @@ -31,10 +34,12 @@ async def _upsert( for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all() } changed = [] + inserted: set[Any] = set() for row in rows: current = existing.get(row[key]) if current is None: stat.inserted += 1 + inserted.add(row[key]) changed.append(row) elif current != tuple(row[name] for name in value_names): stat.updated += 1 @@ -47,38 +52,48 @@ async def _upsert( index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names} ) await session.execute(statement, changed) + return inserted MATRIX_SKIPPED = ( - "матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором " - "(правка — PUT /admin/roles/{code}/permissions)" + "матрица прав уже заполнена; seed не возвращает права, снятые администратором, и добавляет только связи ролей " + "и прав, впервые созданных этим запуском (правка — PUT /admin/roles/{code}/permissions)" ) -async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: - """Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7). +async def _apply_role_permissions( # noqa: PLR0913 - набор, статистика и коды впервые вставленных ролей и прав + session: AsyncSession, + data: SeedData, + stat: TableStat, + *, + new_roles: set[str], + new_permissions: set[str], +) -> None: + """Матрица «роль × право» из файла (issue #9 п.7, ревью R-3). - Если связи уже есть, матрицей владеет администратор: шаг пропускается целиком (`stat.skipped_reason`), снятые - права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся. + Пустая `role_permission` (первичная загрузка, в том числе после того, как администратор снял все связи) — + вставляется вся матрица файла. Непустая — матрицей владеет администратор: вставляются только пары, у которых + роль ИЛИ право вставлены этим же прогоном (`new_roles`/`new_permissions` — их администратор снять не мог); + остальные пары не трогаются (`stat.skipped_reason`): снятые права не возвращаются, добавленные не отзываются. """ - if await session.scalar(select(RolePermission.role_id).limit(1)) is not None: + matrix_filled = await session.scalar(select(RolePermission.role_id).limit(1)) is not None + if matrix_filled: stat.skipped_reason = MATRIX_SKIPPED - return 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()} - ) + new_rows = [ + { + "role_id": role_ids[role_code], + "permission_id": permission_ids[code], + "granted_by": None, + "granted_at": func.now(), + } + for role_code, codes in data.role_permissions.items() + for code in codes + if not matrix_filled or role_code in new_roles or code in new_permissions + ] if new_rows: + stat.inserted += len(new_rows) # .values(): func.now() допустим только во встроенном VALUES, не в executemany statement = ( pg_insert(RolePermission) @@ -226,7 +241,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: [{"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( + new_roles = await _upsert( session, Role, "code", @@ -242,7 +257,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: ], report.stat("role"), ) - await _upsert( + new_permissions = await _upsert( session, Permission, "code", @@ -258,7 +273,9 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: ], report.stat("permission"), ) - await _apply_role_permissions(session, data, report.stat("role_permission")) + await _apply_role_permissions( + session, data, report.stat("role_permission"), new_roles=new_roles, new_permissions=new_permissions + ) await _apply_chart_of_accounts(session, data, report.stat("chart_of_account")) await apply_elimination_rules( session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account") diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index b7be21a..03bca4e 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -133,7 +133,7 @@ class TableStat: inserted: int = 0 updated: int = 0 unchanged: int = 0 - #: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI + #: шаг выполнен не полностью или пропущен (матрица прав уже заполнена); текст — для отчёта CLI skipped_reason: str | None = None @@ -148,10 +148,12 @@ class SeedReport: def format(self) -> str: lines = ["Seed применён (вставлено / обновлено / без изменений):"] - lines += [ - f" {name}: пропущено — {stat.skipped_reason}" - if stat.skipped_reason is not None - else f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" - for name, stat in self.tables.items() - ] + for name, stat in self.tables.items(): + counts = f"{stat.inserted} / {stat.updated} / {stat.unchanged}" + if stat.skipped_reason is None: + lines.append(f" {name}: {counts}") + elif stat.inserted: + lines.append(f" {name}: {counts} (только связи новых ролей и прав; {stat.skipped_reason})") + else: + lines.append(f" {name}: пропущено — {stat.skipped_reason}") return "\n".join(lines) diff --git a/api/seed/core/role_permissions.yaml b/api/seed/core/role_permissions.yaml index 0684198..8c5559f 100644 --- a/api/seed/core/role_permissions.yaml +++ b/api/seed/core/role_permissions.yaml @@ -2,8 +2,9 @@ # ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI. # Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия # policy-слоя, а не отдельные права, поэтому здесь не представлены. -# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право); -# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает. +# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml; granted_by = NULL (выдано seed'ом). +# Матрица загружается целиком только в ПУСТУЮ role_permission; в заполненную — лишь пары ролей/прав, впервые созданных +# этим запуском seed (снятые администратором права не возвращаются). Права, которых в файле нет, seed не отзывает. ADMIN: - DIRECTORY_VIEW - DIRECTORY_EDIT diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index bd5a30a..64fd184 100644 --- a/api/tests/test_seed_roles.py +++ b/api/tests/test_seed_roles.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest import yaml -from sqlalchemy import func, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed import SeedError, SeedReport, load_seed, run_seed @@ -370,3 +370,59 @@ def test_cli_help() -> None: assert result.returncode == 0 assert "--dev" in result.stdout + + +@pytest.mark.integration +async def test_reseed_grants_only_pairs_of_new_roles_and_permissions(db_session: AsyncSession, tmp_path: Path) -> None: + """Матрица непуста: добавляются только связи ролей/прав, вставленных этим прогоном (их снять не могли, ревью R-3); + снятая администратором связь существующих роли и права не возвращается.""" + seed_dir = _copy_seed(tmp_path) + await run_seed(db_session, seed_dir=seed_dir) + expert_view = ( + select(RolePermission) + .join(Role, Role.id == RolePermission.role_id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.code == "EXPERT", Permission.code == "EX_VIEW") + ) + await db_session.delete((await db_session.execute(expert_view)).scalar_one()) + await db_session.flush() + core = seed_dir / "core" + with (core / "permissions.yaml").open("a", encoding="utf-8") as file: + file.write('- code: AUDIT_EXPORT\n object: AUDIT\n action: EXPORT\n name: "Аудит: выгрузка"\n') + with (core / "roles.yaml").open("a", encoding="utf-8") as file: + file.write("- code: AUDITOR\n name: Аудитор\n is_system: true\n is_active: true\n") + matrix_path = core / "role_permissions.yaml" + matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8")) + matrix["ADMIN"].append("AUDIT_EXPORT") # существующая роль × новое право + matrix["AUDITOR"] = ["AUDIT_VIEW"] # новая роль × существующее право + matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8") + + report = await run_seed(db_session, seed_dir=seed_dir) + + rows = ( + await db_session.execute( + select(Role.code, Permission.code) + .join(RolePermission, RolePermission.role_id == Role.id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.code.in_(["ADMIN", "AUDITOR"]), Permission.code.in_(["AUDIT_EXPORT", "AUDIT_VIEW"])) + ) + ).all() + assert set(rows) == {("ADMIN", "AUDIT_EXPORT"), ("ADMIN", "AUDIT_VIEW"), ("AUDITOR", "AUDIT_VIEW")} + assert (await db_session.execute(expert_view)).scalar_one_or_none() is None + assert await _count(db_session, RolePermission) == GRANT_COUNT - 1 + 2 + stat = report.tables["role_permission"] + assert (stat.inserted, stat.updated, stat.unchanged) == (2, 0, 0) + assert stat.skipped_reason is not None + + +@pytest.mark.integration +async def test_reseed_refills_matrix_when_admin_removed_all_links(db_session: AsyncSession) -> None: + """Пограничный случай: пустая `role_permission` — первичная загрузка, матрица заливается заново.""" + await run_seed(db_session) + await db_session.execute(delete(RolePermission)) + + report = await run_seed(db_session) + + assert await _count(db_session, RolePermission) == GRANT_COUNT + assert report.tables["role_permission"].inserted == GRANT_COUNT + assert report.tables["role_permission"].skipped_reason is None