# 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 logger = logging.getLogger(__name__) _MIGRATION_FLAG_KEY = "migration_root_users_completed" 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 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 rootInterface = getRootInterface() stats = { "usersProcessed": 0, "mandatesCreated": 0, "instancesMigrated": 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, mandateType="personal", mandateName=user.get("fullName") or username, planKey="TRIAL_7D", ) 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: # Note: data migration (rewriting featureInstanceId on data records) is # feature-specific and would need per-feature handlers. For now, we create # the new instance and transfer the access. Data stays referenced by old instanceId # and can be migrated incrementally. logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)") stats["instancesMigrated"] += 1 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}