From 900fdc8ece9d2b9e6fb30e73d84dea2f630f8ed9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:30:09 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20seed=20=D0=BD=D0=B5=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=83=D1=8E=20role=5Fpermission=20(#9=20=D0=BF.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_apply_role_permissions` (`scripts/seed_lib/apply.py`) загружает матрицу из `role_permissions.yaml` только при пустой `role_permission`; иначе шаг пропускается целиком с причиной в отчёте (`TableStat.skipped_reason`, «role_permission: пропущено — …»). Права, снятые администратором, не возвращаются; `role` и `permission` по-прежнему upsert'ятся. Тесты: повторный прогон — «пропущено», матрица та же; снятая связь после повторного seed не возвращается. README «Администрирование» и раздел seed переписаны под новое поведение. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 3 ++- README.md | 14 +++++++++----- api/scripts/seed.py | 3 ++- api/scripts/seed_lib/apply.py | 14 ++++++++++++++ api/scripts/seed_lib/model.py | 9 +++++++-- api/tests/test_seed_dev.py | 3 ++- api/tests/test_seed_roles.py | 36 ++++++++++++++++++++++++++++++++++- 7 files changed, 71 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 490b0d5..1da6884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +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`), идемпотентен; `--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 14213c5..392afe1 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ make seed-dev # справочники продукта + dev-пользов ``` `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без -dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. `make seed-dev` +dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав +`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см. +«Администрирование»). `make seed-dev` (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 («`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. @@ -362,10 +364,12 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о - **Email.** В теле запроса — `EmailStr`, поэтому адрес с зарезервированным доменом `*.local` (`x@corp.local`) через API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. -- **Повторный seed возвращает снятые права.** `make seed` / `make seed-dev` идемпотентны и только **добавляют** - недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли - в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, - seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). +- **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`). - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново. - **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` diff --git a/api/scripts/seed.py b/api/scripts/seed.py index d8bdb62..d6aeb49 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -7,7 +7,8 @@ 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). План счетов (`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 412f0fd..a69d95b 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -49,7 +49,21 @@ async def _upsert( await session.execute(statement, changed) +MATRIX_SKIPPED = ( + "матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором " + "(правка — PUT /admin/roles/{code}/permissions)" +) + + async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: + """Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7). + + Если связи уже есть, матрицей владеет администратор: шаг пропускается целиком (`stat.skipped_reason`), снятые + права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся. + """ + if await session.scalar(select(RolePermission.role_id).limit(1)) is not None: + 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()) diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index fce5301..b7be21a 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -133,11 +133,13 @@ class TableStat: inserted: int = 0 updated: int = 0 unchanged: int = 0 + #: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI + skipped_reason: str | None = None @dataclass class SeedReport: - """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось.""" + """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось (или почему шаг пропущен).""" tables: dict[str, TableStat] = field(default_factory=dict) @@ -147,6 +149,9 @@ class SeedReport: def format(self) -> str: lines = ["Seed применён (вставлено / обновлено / без изменений):"] lines += [ - f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" for name, stat in self.tables.items() + 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() ] return "\n".join(lines) diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index 1ccaae2..2099ed7 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -110,9 +110,10 @@ async def test_second_dev_run_changes_nothing(db_session: AsyncSession) -> None: assert await _counts(db_session) == before assert before["app_user"] == USER_COUNT assert before["user_role"] == USER_COUNT - for table in ("regional_office", "app_user", "user_role"): + for table in ("regional_office", "app_user", "user_role", "role_permission"): stat = report.tables[table] assert (stat.inserted, stat.updated) == (0, 0) + assert report.tables["role_permission"].skipped_reason is not None # матрица непуста — не трогается db_session.expire_all() assert {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} == stamps diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index 76efd00..bd5a30a 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 +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed import SeedError, SeedReport, load_seed, run_seed @@ -146,6 +146,40 @@ async def test_second_run_does_not_change_row_counts(db_session: AsyncSession) - assert await _counts(db_session) == first assert all(stat.inserted == 0 and stat.updated == 0 for stat in report.tables.values()) + # матрица прав непуста — шаг пропущен целиком (issue #9 п.7) + assert report.tables["role_permission"].skipped_reason is not None + assert report.tables["role_permission"].unchanged == 0 + assert "role_permission: пропущено" in report.format() + + +@pytest.mark.integration +async def test_first_run_fills_matrix_without_skip(db_session: AsyncSession) -> None: + report = await run_seed(db_session) + + assert report.tables["role_permission"].skipped_reason is None + assert "пропущено" not in report.format() + + +@pytest.mark.integration +async def test_reseed_does_not_restore_permission_revoked_by_admin(db_session: AsyncSession) -> None: + """Связь «роль × право», снятая администратором, повторным seed не возвращается; роли и права — upsert.""" + await run_seed(db_session) + 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() + await db_session.execute(update(Role).where(Role.code == "EXPERT").values(name="Изменено вручную")) + + report = await run_seed(db_session) + + assert (await db_session.execute(expert_view)).scalar_one_or_none() is None + assert await _count(db_session, RolePermission) == GRANT_COUNT - 1 + assert report.tables["role_permission"].skipped_reason is not None + assert report.tables["role"].updated == 1 # роли по-прежнему приводятся к файлу @pytest.mark.integration