213 lines
8.6 KiB
Python
213 lines
8.6 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
|
|
|
|
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}
|