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:
parent
b17176f97c
commit
900fdc8ece
@ -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`).
|
||||||
|
|
||||||
## Зависимости от соседей
|
## Зависимости от соседей
|
||||||
|
|||||||
14
README.md
14
README.md
@ -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`
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user