fix: seed не возвращает снятые права — матрица только в пустую role_permission (#9 п.7)

`_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:30:09 +04:00
parent b17176f97c
commit 900fdc8ece
7 changed files with 71 additions and 11 deletions

View File

@ -76,7 +76,8 @@
dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`. dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта), Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`). `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`). **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).
## Зависимости от соседей ## Зависимости от соседей

View File

@ -87,7 +87,9 @@ make seed-dev # справочники продукта + dev-пользов
``` ```
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без `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` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2
(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. («`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД.
@ -362,10 +364,12 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
- **Email.** В теле запроса — `EmailStr`, поэтому адрес с зарезервированным доменом `*.local` (`x@corp.local`) через - **Email.** В теле запроса — `EmailStr`, поэтому адрес с зарезервированным доменом `*.local` (`x@corp.local`) через
API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной
проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.
- **Повторный seed возвращает снятые права.** `make seed` / `make seed-dev` идемпотентны и только **добавляют** - **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml`
недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли `make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если
в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`:
seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`,
`permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде
(например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`).
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
страницу или войдите заново. страницу или войдите заново.
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` - **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after`

View File

@ -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'ы из 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из
`src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается.
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
ключу, без удалений. ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг
пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7).
План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле План счетов (`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, Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46,

View File

@ -49,7 +49,21 @@ async def _upsert(
await session.execute(statement, changed) 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: 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()} 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()} 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()) existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all())

View File

@ -133,11 +133,13 @@ class TableStat:
inserted: int = 0 inserted: int = 0
updated: int = 0 updated: int = 0
unchanged: int = 0 unchanged: int = 0
#: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI
skipped_reason: str | None = None
@dataclass @dataclass
class SeedReport: class SeedReport:
"""Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось.""" """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось (или почему шаг пропущен)."""
tables: dict[str, TableStat] = field(default_factory=dict) tables: dict[str, TableStat] = field(default_factory=dict)
@ -147,6 +149,9 @@ class SeedReport:
def format(self) -> str: def format(self) -> str:
lines = ["Seed применён (вставлено / обновлено / без изменений):"] lines = ["Seed применён (вставлено / обновлено / без изменений):"]
lines += [ 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) return "\n".join(lines)

View File

@ -110,9 +110,10 @@ async def test_second_dev_run_changes_nothing(db_session: AsyncSession) -> None:
assert await _counts(db_session) == before assert await _counts(db_session) == before
assert before["app_user"] == USER_COUNT assert before["app_user"] == USER_COUNT
assert before["user_role"] == 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] stat = report.tables[table]
assert (stat.inserted, stat.updated) == (0, 0) assert (stat.inserted, stat.updated) == (0, 0)
assert report.tables["role_permission"].skipped_reason is not None # матрица непуста — не трогается
db_session.expire_all() db_session.expire_all()
assert {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} == stamps assert {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} == stamps

View File

@ -10,7 +10,7 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import SeedError, SeedReport, load_seed, run_seed 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 await _counts(db_session) == first
assert all(stat.inserted == 0 and stat.updated == 0 for stat in report.tables.values()) 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 @pytest.mark.integration