329 lines
13 KiB
Python
329 lines
13 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Migration: Root-Mandant bereinigen.
|
|
Moves all end-user data from Root mandate shared instances to own mandates.
|
|
Called once from bootstrap, sets a DB flag to prevent re-execution.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
|
|
|
|
_DATA_TABLES = [
|
|
"ChatWorkflow",
|
|
"FileItem",
|
|
"DataSource",
|
|
"DataNeutralizerAttributes",
|
|
"FileContentIndex",
|
|
]
|
|
|
|
|
|
def _isMigrationCompleted(db) -> bool:
|
|
"""Check if migration has already been executed."""
|
|
try:
|
|
from modules.datamodels.datamodelUam import Mandate
|
|
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
|
return len(records) > 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _setMigrationCompleted(db) -> None:
|
|
"""Set flag that migration is completed (uses a settings-like record)."""
|
|
if _isMigrationCompleted(db):
|
|
return
|
|
try:
|
|
from modules.datamodels.datamodelUam import Mandate
|
|
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
|
|
db.recordCreate(Mandate, flag)
|
|
logger.info("Migration flag set: root user migration completed")
|
|
except Exception as e:
|
|
logger.error(f"Failed to set migration flag: {e}")
|
|
|
|
|
|
def _findOrCreateTargetInstance(db, featureInterface, featureCode: str, targetMandateId: str, rootInstance: dict) -> dict:
|
|
"""Find existing or create new FeatureInstance in target mandate. Idempotent."""
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
|
|
existing = db.getRecordset(FeatureInstance, recordFilter={
|
|
"featureCode": featureCode,
|
|
"mandateId": targetMandateId,
|
|
})
|
|
if existing:
|
|
logger.debug(f"Target instance already exists for {featureCode} in mandate {targetMandateId}")
|
|
return existing[0]
|
|
|
|
label = rootInstance.get("label") or featureCode
|
|
instance = featureInterface.createFeatureInstance(
|
|
featureCode=featureCode,
|
|
mandateId=targetMandateId,
|
|
label=label,
|
|
enabled=True,
|
|
copyTemplateRoles=True,
|
|
)
|
|
if isinstance(instance, dict):
|
|
return instance
|
|
return instance.model_dump() if hasattr(instance, "model_dump") else {"id": instance.id}
|
|
|
|
|
|
def _migrateDataRecords(db, oldInstanceId: str, newInstanceId: str, userId: str) -> int:
|
|
"""Bulk-update featureInstanceId on all data tables for records owned by userId."""
|
|
totalMigrated = 0
|
|
db._ensure_connection()
|
|
for tableName in _DATA_TABLES:
|
|
try:
|
|
with db.connection.cursor() as cursor:
|
|
cursor.execute(
|
|
f'UPDATE "{tableName}" '
|
|
f'SET "featureInstanceId" = %s '
|
|
f'WHERE "featureInstanceId" = %s AND "sysCreatedBy" = %s',
|
|
(newInstanceId, oldInstanceId, userId),
|
|
)
|
|
count = cursor.rowcount
|
|
db.connection.commit()
|
|
if count > 0:
|
|
logger.info(f" Migrated {count} rows in {tableName}: {oldInstanceId} -> {newInstanceId}")
|
|
totalMigrated += count
|
|
except Exception as e:
|
|
try:
|
|
db.connection.rollback()
|
|
except Exception:
|
|
pass
|
|
logger.debug(f" Table {tableName} skipped (may not exist or no matching column): {e}")
|
|
return totalMigrated
|
|
|
|
|
|
def _grantFeatureAccess(db, userId: str, featureInstanceId: str) -> dict:
|
|
"""Create FeatureAccess + admin role on a feature instance. Idempotent."""
|
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
from modules.datamodels.datamodelRbac import Role
|
|
|
|
existing = db.getRecordset(FeatureAccess, recordFilter={
|
|
"userId": userId,
|
|
"featureInstanceId": featureInstanceId,
|
|
})
|
|
if existing:
|
|
logger.debug(f"FeatureAccess already exists for user {userId} on instance {featureInstanceId}")
|
|
return existing[0]
|
|
|
|
fa = FeatureAccess(userId=userId, featureInstanceId=featureInstanceId, enabled=True)
|
|
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
|
|
if not createdFa:
|
|
logger.warning(f"Failed to create FeatureAccess for user {userId} on instance {featureInstanceId}")
|
|
return {}
|
|
|
|
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": featureInstanceId})
|
|
adminRoleId = None
|
|
for r in instanceRoles:
|
|
roleLabel = (r.get("roleLabel") or "").lower()
|
|
if roleLabel.endswith("-admin"):
|
|
adminRoleId = r.get("id")
|
|
break
|
|
if not adminRoleId:
|
|
raise ValueError(
|
|
f"No feature-specific admin role for instance {featureInstanceId}. "
|
|
f"Cannot create FeatureAccess without role — even in migration context."
|
|
)
|
|
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
|
|
db.recordCreate(FeatureAccessRole, far.model_dump())
|
|
|
|
return createdFa
|
|
|
|
|
|
def migrateRootUsers(db, dryRun: bool = False) -> dict:
|
|
"""
|
|
Migrate all end-user feature data from Root mandate to personal mandates.
|
|
|
|
Algorithm:
|
|
STEP 1: For each user with FeatureAccess on Root instances:
|
|
- If user has own mandate: target = existing mandate
|
|
- If not: create personal mandate via _provisionMandateForUser
|
|
- For each FeatureAccess: create new instance in target, migrate data, transfer access
|
|
|
|
STEP 2: Clean up Root:
|
|
- Delete all FeatureInstances in Root
|
|
- Remove UserMandate for non-sysadmin users
|
|
|
|
Args:
|
|
db: Database connector
|
|
dryRun: If True, log actions without making changes
|
|
|
|
Returns:
|
|
Summary dict with migration statistics
|
|
"""
|
|
if _isMigrationCompleted(db):
|
|
logger.info("Root user migration already completed, skipping")
|
|
return {"status": "already_completed"}
|
|
|
|
from modules.datamodels.datamodelUam import Mandate, User, UserInDB
|
|
from modules.datamodels.datamodelMembership import (
|
|
UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
|
|
)
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(db)
|
|
stats = {
|
|
"usersProcessed": 0,
|
|
"mandatesCreated": 0,
|
|
"instancesMigrated": 0,
|
|
"dataRowsMigrated": 0,
|
|
"rootInstancesDeleted": 0,
|
|
"rootMembershipsRemoved": 0,
|
|
"dryRun": dryRun,
|
|
}
|
|
|
|
# Find root mandate
|
|
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
|
if not rootMandates:
|
|
logger.warning("No root mandate found, nothing to migrate")
|
|
return {"status": "no_root_mandate"}
|
|
rootMandateId = rootMandates[0].get("id")
|
|
|
|
# Get all feature instances in root
|
|
rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
|
|
if not rootInstances:
|
|
logger.info("No feature instances in root mandate, nothing to migrate")
|
|
if not dryRun:
|
|
_setMigrationCompleted(db)
|
|
return {"status": "no_instances", **stats}
|
|
|
|
# Get all FeatureAccess on root instances
|
|
rootInstanceIds = {inst.get("id") for inst in rootInstances}
|
|
|
|
# Collect unique users with access on root instances
|
|
usersToMigrate = {}
|
|
for instanceId in rootInstanceIds:
|
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
|
for access in accesses:
|
|
userId = access.get("userId")
|
|
if userId not in usersToMigrate:
|
|
usersToMigrate[userId] = []
|
|
usersToMigrate[userId].append({
|
|
"featureAccessId": access.get("id"),
|
|
"featureInstanceId": instanceId,
|
|
})
|
|
|
|
logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
|
|
|
|
# STEP 1: Migrate users
|
|
for userId, accessList in usersToMigrate.items():
|
|
try:
|
|
# Find user
|
|
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
|
|
if not users:
|
|
logger.warning(f"User {userId} not found, skipping")
|
|
continue
|
|
user = users[0]
|
|
username = user.get("username", "unknown")
|
|
|
|
# Check if user has own non-root mandate
|
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
|
targetMandateId = None
|
|
for um in userMandates:
|
|
mid = um.get("mandateId")
|
|
if mid != rootMandateId:
|
|
targetMandateId = mid
|
|
break
|
|
|
|
if not targetMandateId:
|
|
# Create personal mandate
|
|
if dryRun:
|
|
logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
|
|
stats["mandatesCreated"] += 1
|
|
else:
|
|
try:
|
|
result = rootInterface._provisionMandateForUser(
|
|
userId=userId,
|
|
mandateName=f"Home {username}",
|
|
planKey="TRIAL_14D",
|
|
)
|
|
targetMandateId = result["mandateId"]
|
|
stats["mandatesCreated"] += 1
|
|
logger.info(f"Created personal mandate {targetMandateId} for user {username}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to create mandate for user {username}: {e}")
|
|
continue
|
|
|
|
# Migrate each FeatureAccess
|
|
for accessInfo in accessList:
|
|
oldInstanceId = accessInfo["featureInstanceId"]
|
|
oldAccessId = accessInfo["featureAccessId"]
|
|
|
|
# Find the root instance details
|
|
instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
|
|
if not instRecords:
|
|
continue
|
|
featureCode = instRecords[0].get("featureCode")
|
|
|
|
if dryRun:
|
|
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
|
|
stats["instancesMigrated"] += 1
|
|
else:
|
|
targetInstance = _findOrCreateTargetInstance(
|
|
db, featureInterface, featureCode, targetMandateId, instRecords[0],
|
|
)
|
|
newInstanceId = targetInstance.get("id")
|
|
if not newInstanceId:
|
|
logger.error(f"Failed to obtain target instance for {featureCode} in mandate {targetMandateId}")
|
|
continue
|
|
|
|
migratedCount = _migrateDataRecords(db, oldInstanceId, newInstanceId, userId)
|
|
|
|
_grantFeatureAccess(db, userId, newInstanceId)
|
|
|
|
try:
|
|
db.recordDelete(FeatureAccess, oldAccessId)
|
|
except Exception as delErr:
|
|
logger.warning(f"Could not remove old FeatureAccess {oldAccessId}: {delErr}")
|
|
|
|
logger.info(
|
|
f"Migrated {featureCode} for {username}: "
|
|
f"instance {oldInstanceId} -> {newInstanceId}, {migratedCount} data rows moved"
|
|
)
|
|
stats["instancesMigrated"] += 1
|
|
stats["dataRowsMigrated"] += migratedCount
|
|
|
|
stats["usersProcessed"] += 1
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error migrating user {userId}: {e}")
|
|
|
|
# STEP 2: Clean up root
|
|
if not dryRun:
|
|
# Delete all feature instances in root
|
|
for inst in rootInstances:
|
|
instId = inst.get("id")
|
|
try:
|
|
# First delete all FeatureAccess on this instance
|
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
|
|
for access in accesses:
|
|
db.recordDelete(FeatureAccess, access.get("id"))
|
|
db.recordDelete(FeatureInstance, instId)
|
|
stats["rootInstancesDeleted"] += 1
|
|
except Exception as e:
|
|
logger.error(f"Error deleting root instance {instId}: {e}")
|
|
|
|
# Remove non-sysadmin users from root mandate
|
|
rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
|
|
for membership in rootMembers:
|
|
membUserId = membership.get("userId")
|
|
userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
|
|
if userRecords and userRecords[0].get("isSysAdmin"):
|
|
continue
|
|
try:
|
|
db.recordDelete(UserMandate, membership.get("id"))
|
|
stats["rootMembershipsRemoved"] += 1
|
|
except Exception as e:
|
|
logger.error(f"Error removing root membership for {membUserId}: {e}")
|
|
|
|
_setMigrationCompleted(db)
|
|
|
|
logger.info(f"Migration completed: {stats}")
|
|
return {"status": "completed", **stats}
|