Andrey Epifancev 900fdc8ece 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
2026-10-05 13:30:09 +04:00

270 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
import uuid
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules
from scripts.seed_lib.model import SeedData, SeedReport, TableStat
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
from src.models.accounting import ChartOfAccount
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)
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())
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_chart_of_accounts(session: AsyncSession, data: SeedData, stat: TableStat) -> None:
"""План счетов: upsert по `code` в порядке глубины (родитель раньше потомков), одним `run_seed` и одной транзакцией.
Идентификаторы новых строк генерирует приложение заранее, поэтому `parent_id` известен при вставке и второй проход
не нужен; существующим строкам при расхождении с файлом обновляются поля и `parent_id`. Удалений нет.
"""
if not data.accounts:
return
stored = (
await session.execute(
select(
ChartOfAccount.code,
ChartOfAccount.id,
ChartOfAccount.parent_id,
ChartOfAccount.name,
ChartOfAccount.kind,
ChartOfAccount.row_type,
ChartOfAccount.is_off_balance,
)
)
).all()
ids = {row.code: row.id for row in stored}
existing = {row.code: (row.parent_id, row.name, row.kind, row.row_type, row.is_off_balance) for row in stored}
for code in data.accounts:
ids.setdefault(code, uuid.uuid4())
rows = []
for code, account in sorted(data.accounts.items(), key=lambda item: item[0].count(".")):
parent_id = ids[account.parent_code] if account.parent_code is not None else None
values = (parent_id, account.name, account.kind, account.row_type, account.is_off_balance)
if code not in existing:
stat.inserted += 1
elif existing[code] != values:
stat.updated += 1
else:
stat.unchanged += 1
continue
rows.append(
{
"id": ids[code],
"code": code,
"parent_id": parent_id,
"name": account.name,
"kind": account.kind,
"row_type": account.row_type,
"is_off_balance": account.is_off_balance,
}
)
if rows:
statement = pg_insert(ChartOfAccount)
statement = statement.on_conflict_do_update(
index_elements=[ChartOfAccount.code],
set_={
name: statement.excluded[name] for name in ("parent_id", "name", "kind", "row_type", "is_off_balance")
},
)
await session.execute(statement, rows)
async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableStat, link_stat: TableStat) -> None:
"""Dev-пользователи: upsert `app_user` по `email`, затем `user_role` по (user_id, role_id).
`created_at` ставится при вставке, `updated_at` — при вставке и при изменении данных (триггера в БД нет);
неизменившиеся строки не трогаются. Назначения ролей только добавляются: роль, убранная из файла, остаётся
назначенной (удалений нет). `external_id` не задаётся (SSO-субъект dev-пользователя неизвестен).
"""
if not data.users:
return
office_ids = {
name: id_ for id_, name in (await session.execute(select(RegionalOffice.id, RegionalOffice.name))).all()
}
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
stored = {
row.email: row
for row in (
await session.execute(
select(
AppUser.id,
AppUser.email,
AppUser.full_name,
AppUser.employee_no,
AppUser.regional_office_id,
AppUser.business_segment,
AppUser.is_active,
)
)
).all()
}
user_ids: dict[str, Any] = {}
for user in data.users:
values = {
"full_name": user.full_name,
"employee_no": user.employee_no,
"regional_office_id": office_ids[user.regional_office],
"business_segment": user.business_segment,
"is_active": user.is_active,
}
row = stored.get(user.email)
if row is None:
user_stat.inserted += 1
user_ids[user.email] = uuid.uuid4()
await session.execute(
pg_insert(AppUser).values(
id=user_ids[user.email], email=user.email, **values, created_at=func.now(), updated_at=func.now()
)
)
continue
user_ids[user.email] = row.id
if any(getattr(row, name) != value for name, value in values.items()):
user_stat.updated += 1
await session.execute(update(AppUser).where(AppUser.id == row.id).values(**values, updated_at=func.now()))
else:
user_stat.unchanged += 1
existing = set((await session.execute(select(UserRole.user_id, UserRole.role_id))).all())
new_rows = []
for user in data.users:
for role_code in user.role_codes:
pair = (user_ids[user.email], role_ids[role_code])
if pair in existing:
link_stat.unchanged += 1
else:
link_stat.inserted += 1
new_rows.append({"id": uuid.uuid4(), "user_id": pair[0], "role_id": pair[1]})
if new_rows:
await session.execute(
pg_insert(UserRole).on_conflict_do_nothing(index_elements=["user_id", "role_id"]), new_rows
)
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"))
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")
)
await apply_required_document_rules(session, data, report.stat("required_document_rule"))
# dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`)
await _apply_users(session, data, report.stat("app_user"), report.stat("user_role"))
return report