From 63e30c128182ddaaa829651a37961855fd40da67 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 11 Jun 2026 22:54:39 +0200 Subject: [PATCH] import cleanup --- app.py | 10 +- env-dev.env | 2 +- env-int.env | 2 +- env-prod.env | 2 +- modules/auth/trustedDeviceService.py | 48 + .../commcoach/interfaceFeatureCommcoach.py | 11 - .../commcoach/routeFeatureCommcoach.py | 5 - .../features/commcoach/serviceCommcoach.py | 4 - .../serviceNeutralization/subProcessList.py | 2 - .../realEstate/interfaceFeatureRealEstate.py | 1 - .../features/teamsbot/routeFeatureTeamsbot.py | 9 - .../trustee/accounting/accountingBridge.py | 4 - .../trustee/interfaceFeatureTrustee.py | 2 - .../features/trustee/routeFeatureTrustee.py | 7 - .../methodTrustee/actions/extractFromFiles.py | 1 - .../workspace/routeFeatureWorkspace.py | 1 - modules/interfaces/interfaceAiObjects.py | 2 - modules/interfaces/interfaceDbApp.py | 18 - modules/interfaces/interfaceDbBilling.py | 2 - modules/interfaces/interfaceDbChat.py | 2 - modules/interfaces/interfaceDbKnowledge.py | 1 - modules/interfaces/interfaceDbManagement.py | 5 - modules/interfaces/interfaceRbac.py | 6 - modules/interfaces/interfaceTableHelpers.py | 2 - modules/routes/routeAdminFeatures.py | 1 - modules/routes/routeAdminRbacRules.py | 3 - modules/routes/routeBilling.py | 2 - modules/routes/routeDataConnections.py | 3 - modules/routes/routeDataFiles.py | 2 - modules/routes/routeDataPrompts.py | 1 - modules/routes/routeMfa.py | 1 - modules/routes/routeNotifications.py | 1 - modules/routes/routeRagInventory.py | 1 - modules/routes/routeSecurityLocal.py | 4 - modules/routes/routeWorkflowAutomation.py | 7 - .../services/serviceAgent/mainServiceAgent.py | 7 - .../services/serviceAi/mainServiceAi.py | 2 - .../services/serviceChat/mainServiceChat.py | 2 - .../extractors/extractorContainer.py | 1 - .../extractors/extractorEmail.py | 1 - .../extractors/extractorFolder.py | 1 - .../services/serviceExtraction/subRegistry.py | 2 - .../renderers/rendererPdf.py | 1 - .../renderers/rendererPptx.py | 1 - .../renderers/rendererXlsx.py | 1 - .../serviceKnowledge/subConnectorSyncGmail.py | 5 +- .../mainServiceSubscription.py | 2 - modules/system/databaseHealth.py | 2 - .../engine/executors/actionNodeExecutor.py | 1 - modules/workflowAutomation/helpers.py | 2 - .../mainWorkflowAutomation.py | 1 - .../methods/methodAi/actions/process.py | 2 - .../script_analyze_platform_module_graph.py | 568 +++++++++++ scripts/script_analyze_porta_imports.py | 898 ++++++++++++++++++ ...cript_remove_redundant_platform_imports.py | 165 ++++ 55 files changed, 1691 insertions(+), 149 deletions(-) create mode 100644 scripts/script_analyze_platform_module_graph.py create mode 100644 scripts/script_analyze_porta_imports.py create mode 100644 scripts/script_remove_redundant_platform_imports.py diff --git a/app.py b/app.py index 1ce31ae5..1df8510f 100644 --- a/app.py +++ b/app.py @@ -380,7 +380,6 @@ async def lifespan(app: FastAPI): # Register all feature definitions in RBAC catalog (for /api/features/ endpoint) try: from modules.security.rbacCatalog import getCatalogService - from modules.system.registry import registerAllFeaturesInCatalog, syncCatalogFeaturesToDb catalogService = getCatalogService() registerAllFeaturesInCatalog(catalogService) logger.info("Feature catalog registration completed") @@ -494,7 +493,6 @@ async def lifespan(app: FastAPI): def _onRunFailed(workflowId, runId, error, mandateId=None, workflowLabel=None): from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext - from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelMessaging import MessagingEventParameters rootInterface = getRootInterface() @@ -555,6 +553,10 @@ async def lifespan(app: FastAPI): from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler registerEnterpriseRenewalScheduler() + # Register token and trusted device cleanup scheduler + from modules.auth.trustedDeviceService import registerTokenCleanupScheduler + registerTokenCleanupScheduler() + # Recover background jobs that were RUNNING when the previous worker died try: from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import ( @@ -901,6 +903,10 @@ app.include_router(demoConfigRouter) from modules.routes.routeAdminDatabaseHealth import router as adminDatabaseHealthRouter app.include_router(adminDatabaseHealthRouter) +from modules.routes.routeAdminSessions import router as adminSessionsRouter, trustedDeviceRouter as adminTrustedDeviceRouter +app.include_router(adminSessionsRouter) +app.include_router(adminTrustedDeviceRouter) + from modules.routes.routeGdpr import router as gdprRouter app.include_router(gdprRouter) diff --git a/env-dev.env b/env-dev.env index 457cc7a5..179f7caf 100644 --- a/env-dev.env +++ b/env-dev.env @@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09 -STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09 +STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnFLeUFlb2dfSjZPaWIyRjZsNjhiSDFQNFpxdW50YmlLUjFLX1lJMGdCWUtBUEdrRGhvSzVVWnkxNVZEdmtkQmk5X05YS0JVU1NyX3VQZTV2VjVwakd0RGM2WUl6TTlzbms1d1NCOTQtdURiVjhxdXZGVlR1ZVNTbUkwOFh1R04yUUxxay0= STRIPE_API_VERSION = 2026-01-28.clover STRIPE_AUTOMATIC_TAX_ENABLED = false STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0 diff --git a/env-int.env b/env-int.env index 84e0feb4..a8f67e6f 100644 --- a/env-int.env +++ b/env-int.env @@ -60,7 +60,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/a # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09 -STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI= +STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnFLeUFWUUtMZ25NQ2ZOWE5nRF9CaFNwcXhSU2tKRktLaElLRHJMM295OXNkVEFLekVUMzN0YUpIZHJfWGNqa0xxOFZRVHZEUXVLZ3ItVGZWc2VFQ2thcUlJalY1b0JDSmR6RF96d1A3OGhyd0w1MHZPeFNZRkl0c19kYUJQcHVwR2tsd0s= STRIPE_API_VERSION = 2026-01-28.clover STRIPE_AUTOMATIC_TAX_ENABLED = false STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0 diff --git a/env-prod.env b/env-prod.env index 6a2a89d7..686f784b 100644 --- a/env-prod.env +++ b/env-prod.env @@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/ # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09 -STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= +STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnFLeUFNQ1FhVE94ZzM3V3NCVGVVWnltUndsOG1Ra0hQTmJ3QWY5aXVWeTJsX3A4a3VBSnFQd3drWFRZNFVDdWxCeFgyQ0RpNGQ0SlJOcm9tVE5KZmVqQU1WUjFjeDRJeGE5THdmR0g1V2dQUk5SSjcySnAzR245NW5NUFVDT3lJUWpjWFo= STRIPE_API_VERSION = 2026-01-28.clover STRIPE_AUTOMATIC_TAX_ENABLED = false STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah diff --git a/modules/auth/trustedDeviceService.py b/modules/auth/trustedDeviceService.py index a707e70a..d57d5c63 100644 --- a/modules/auth/trustedDeviceService.py +++ b/modules/auth/trustedDeviceService.py @@ -169,3 +169,51 @@ def _getClientIp(request: Request) -> Optional[str]: if request.client: return request.client.host return None + + +# --- Scheduler Integration --- + +async def _runTokenAndDeviceCleanup() -> None: + """Scheduled task: remove expired tokens and trusted devices.""" + try: + from modules.connectors.connectorDbPostgre import ConnectorPostgre + + db = ConnectorPostgre("poweron_app") + now = getUtcTimestamp() + + # Expired auth-session tokens + tokens = db.getRecordset( + Token, + recordFilter={"tokenPurpose": TokenPurpose.AUTH_SESSION.value}, + ) + expiredCount = 0 + for t in tokens: + if t.get("expiresAt", 0) < now: + db.recordDelete(Token, t["id"]) + expiredCount += 1 + + # Expired trusted devices + deviceCount = cleanupExpiredDevices(db) + + if expiredCount or deviceCount: + logger.info( + f"Token cleanup: {expiredCount} expired token(s), " + f"{deviceCount} expired trusted device(s) removed" + ) + except Exception as e: + logger.error(f"Token/device cleanup failed: {e}") + + +def registerTokenCleanupScheduler() -> None: + """Register daily token cleanup job. Call during app startup.""" + try: + from modules.shared.eventManagement import eventManager + + eventManager.registerCron( + jobId="token_device_cleanup", + func=_runTokenAndDeviceCleanup, + cronKwargs={"hour": "4", "minute": "0"}, + ) + logger.info("Token/device cleanup scheduler registered (daily 04:00)") + except Exception as e: + logger.warning(f"Failed to register token cleanup scheduler: {e}") diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index d4c51a27..a2e18770 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -261,35 +261,29 @@ class CommcoachObjects: # ========================================================================= def getPersonas(self, userId: str, instanceId: str) -> List[Dict[str, Any]]: - from .datamodelCommcoach import CoachingPersona builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"}) custom = self.db.getRecordset(CoachingPersona, recordFilter={"userId": userId, "instanceId": instanceId}) all = builtins + custom return [p for p in all if p.get("isActive", True)] def getPersona(self, personaId: str) -> Optional[Dict[str, Any]]: - from .datamodelCommcoach import CoachingPersona records = self.db.getRecordset(CoachingPersona, recordFilter={"id": personaId}) return records[0] if records else None def createPersona(self, data: Dict[str, Any]) -> Dict[str, Any]: - from .datamodelCommcoach import CoachingPersona data["createdAt"] = getIsoTimestamp() data["updatedAt"] = getIsoTimestamp() return self.db.recordCreate(CoachingPersona, data) def updatePersona(self, personaId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: - from .datamodelCommcoach import CoachingPersona updates["updatedAt"] = getIsoTimestamp() return self.db.recordModify(CoachingPersona, personaId, updates) def deletePersona(self, personaId: str) -> bool: - from .datamodelCommcoach import CoachingPersona return self.db.recordDelete(CoachingPersona, personaId) def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]: """All personas (builtin + custom for this instance), including inactive.""" - from .datamodelCommcoach import CoachingPersona builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"}) custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId}) custom = [p for p in custom if p.get("userId") != "system"] @@ -300,11 +294,9 @@ class CommcoachObjects: # ========================================================================= def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]: - from .datamodelCommcoach import ModulePersonaMapping return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId}) def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]: - from .datamodelCommcoach import ModulePersonaMapping existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId}) for rec in existing: self.db.recordDelete(ModulePersonaMapping, rec["id"]) @@ -325,18 +317,15 @@ class CommcoachObjects: # ========================================================================= def getBadges(self, userId: str, instanceId: str) -> List[Dict[str, Any]]: - from .datamodelCommcoach import CoachingBadge records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId}) records.sort(key=lambda r: r.get("awardedAt") or 0, reverse=True) return records def hasBadge(self, userId: str, instanceId: str, badgeKey: str) -> bool: - from .datamodelCommcoach import CoachingBadge records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId, "badgeKey": badgeKey}) return len(records) > 0 def awardBadge(self, data: Dict[str, Any]) -> Dict[str, Any]: - from .datamodelCommcoach import CoachingBadge data["awardedAt"] = getUtcTimestamp() data["createdAt"] = getIsoTimestamp() return self.db.recordCreate(CoachingBadge, data) diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 81e1254d..905ffbc9 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -333,7 +333,6 @@ async def startSession( try: from modules.interfaces.interfaceVoiceObjects import getVoiceInterface voiceInterface = getVoiceInterface(context.user, mandateId) - from .serviceCommcoach import getUserVoicePrefs, stripMarkdownForTts, buildTtsConfigErrorMessage language, voiceName = getUserVoicePrefs(userId, mandateId) ttsResult = await voiceInterface.textToSpeech( text=stripMarkdownForTts(greetingText), @@ -378,7 +377,6 @@ async def startSession( asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface)) async def _newSessionEventGenerator(): - from modules.shared.timeUtils import getIsoTimestamp timeoutCount = 0 try: while True: @@ -468,7 +466,6 @@ async def cancelSession( raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) - from modules.shared.timeUtils import getUtcTimestamp interface.updateSession(sessionId, { "status": CoachingSessionStatus.CANCELLED.value, "endedAt": getUtcTimestamp(), @@ -581,7 +578,6 @@ async def sendAudioStream( if not audioBody: raise HTTPException(status_code=400, detail=routeApiMsg("No audio data received")) - from .serviceCommcoach import getUserVoicePrefs language, _ = getUserVoicePrefs(str(context.user.id), mandateId) moduleId = session.get("moduleId") @@ -765,7 +761,6 @@ async def updateTaskStatus( updates = {"status": body.status.value} if body.status == CoachingTaskStatus.DONE: - from modules.shared.timeUtils import getUtcTimestamp updates["completedAt"] = getUtcTimestamp() updated = interface.updateTask(taskId, updates) diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index b3b5ef2a..315a9dff 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -98,7 +98,6 @@ def getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple: """Load voice language and voiceName from central UserVoicePreferences. Returns (language, voiceName) tuple.""" try: - from modules.datamodels.datamodelUam import UserVoicePreferences from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() prefs = rootIf.db.getRecordset( @@ -430,7 +429,6 @@ async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, A """Pre-AI-call: identify which documents the user references and what action is needed.""" if not docs: return {"read": [], "update": [], "create": [], "noDocumentAction": True} - from . import serviceCommcoachAi as aiPrompts docCatalog = [{"id": d.get("id", ""), "title": d.get("summary") or d.get("fileName", ""), "summary": (d.get("summary") or "")[:100]} for d in docs] prompt = aiPrompts.buildDocumentIntentPrompt(combinedUserPrompt, docCatalog) try: @@ -744,7 +742,6 @@ class CommcoachService: 4. Map agent events to CommCoach SSE events 5. Post-processing: store message, TTS, tasks, scores """ - from . import interfaceFeatureCommcoach as interfaceDb # Store user message userMsg = CoachingMessage( @@ -907,7 +904,6 @@ class CommcoachService: ) agentService = getService("agent", serviceContext) - from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum config = AgentConfig( toolSet="commcoach" if useTools else "none", maxRounds=3 if useTools else 1, diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py index a42904ff..ecc14b78 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessList.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py @@ -157,7 +157,6 @@ class ListProcessor: processedAttrs[attrName] = self.string_parser.mapping[attrValue] else: # Check if attribute value matches any data patterns - from .subPatterns import findPatternsInText, DataPatterns matches = findPatternsInText(attrValue, DataPatterns.patterns) if matches: patternName = matches[0][0] @@ -191,7 +190,6 @@ class ListProcessor: # Skip if already a placeholder if not self.string_parser._isPlaceholder(text): # Check if text matches any patterns - from .subPatterns import findPatternsInText, DataPatterns patternMatches = findPatternsInText(text, DataPatterns.patterns) if patternMatches: diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 24fe4955..5d1ea3a6 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -796,7 +796,6 @@ class RealEstateObjects: return False tableName = modelClass.__name__ - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index b2ac2980..c4219ac7 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -328,7 +328,6 @@ async def startSession( if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT: systemBot = interface.getActiveSystemBot(mandateId) if not systemBot: - from .datamodelTeamsbot import TeamsbotSystemBot allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True}) if allBots: systemBot = allBots[0] @@ -537,7 +536,6 @@ async def streamSession( async def _eventGenerator(): """Generate SSE events from the session event queue.""" - from .service import sessionEvents # Send initial session state with stats stats = interface.getSessionStats(sessionId) @@ -545,7 +543,6 @@ async def streamSession( # Send current bot WebSocket connection state so the operator UI can # render the live indicator without waiting for the next connect/disconnect. - from .service import getActiveService as _getActiveService yield f"data: {json.dumps({'type': 'botConnectionState', 'data': {'connected': _getActiveService(sessionId) is not None}})}\n\n" # Stream events @@ -1040,7 +1037,6 @@ async def submitDirectorPrompt( detail=routeApiMsg(f"Too many files ({len(fileIds)}); max {DIRECTOR_PROMPT_FILE_LIMIT}"), ) - from .service import getActiveService service = getActiveService(sessionId) if not service: raise HTTPException( @@ -1108,7 +1104,6 @@ async def deleteDirectorPrompt( if not context.isPlatformAdmin and prompt.get("operatorUserId") != str(context.user.id): raise HTTPException(status_code=404, detail=f"Prompt '{promptId}' not found") - from .service import getActiveService service = getActiveService(sessionId) if service: await service.removePersistentPrompt(promptId) @@ -1134,7 +1129,6 @@ async def testVoice( ): """Test TTS voice with AI-generated sample text in the correct language.""" from modules.interfaces.interfaceVoiceObjects import getVoiceInterface - from .service import createAiService from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum mandateId = _validateInstanceAccess(instanceId, context) @@ -1547,7 +1541,6 @@ async def postTranscript( originalUser = rootUser # Process transcript through the service pipeline - from .service import TeamsbotService from modules.interfaces.interfaceVoiceObjects import getVoiceInterface service = TeamsbotService(originalUser, mandateId, instanceId, config) @@ -1600,7 +1593,6 @@ async def postBotStatus( if not originalUser: originalUser = rootUser - from .service import TeamsbotService service = TeamsbotService(originalUser, mandateId, instanceId, config) interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId) @@ -1640,7 +1632,6 @@ async def botWebsocket( # Load the original user who started the session (has RBAC roles in mandate) # Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record. - from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() rootUser = rootInterface.currentUser diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py index 51433bbf..d9b9fe73 100644 --- a/modules/features/trustee/accounting/accountingBridge.py +++ b/modules/features/trustee/accounting/accountingBridge.py @@ -33,7 +33,6 @@ class AccountingBridge: async def getActiveConfig(self, featureInstanceId: str) -> Optional[Dict[str, Any]]: """Load the active TrusteeAccountingConfig for a feature instance.""" - from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig records = self._trusteeInterface.db.getRecordset( TrusteeAccountingConfig, recordFilter={"featureInstanceId": featureInstanceId, "isActive": True}, @@ -128,7 +127,6 @@ class AccountingBridge: Optional _resolved* params allow pushBatchToAccounting to pass a pre-resolved connector/config so we don't decrypt per position (avoids rate-limit). """ - from modules.features.trustee.datamodelFeatureTrustee import TrusteePosition, TrusteeAccountingSync connector = _resolvedConnector plainConfig = _resolvedPlainConfig @@ -306,7 +304,6 @@ class AccountingBridge: # Update last sync on config record if configRecord: - from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig updatePayload = { "lastSyncAt": time.time(), "lastSyncStatus": "success" if result.success else "error", @@ -335,7 +332,6 @@ class AccountingBridge: async def refreshChartOfAccounts(self, featureInstanceId: str) -> List[AccountingChart]: """Fetch the full chart of accounts from the external system and cache it locally on TrusteeAccountingConfig.""" - from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId) if not connector or not plainConfig or not configRecord: diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 1e13f185..249b2d50 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -309,7 +309,6 @@ class TrusteeObjects: return False tableName = modelClass.__name__ - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, @@ -338,7 +337,6 @@ class TrusteeObjects: return AccessLevel.NONE tableName = modelClass.__name__ - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index dbc96013..6e6b9924 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -170,7 +170,6 @@ def getQuickActions( if role and role.roleLabel: userRoleLabels.add(role.roleLabel) - from modules.shared.i18nRegistry import resolveText lang = (language or "de").strip() or "de" @@ -1201,7 +1200,6 @@ def _buildSyncStatusByPosition(interface, instanceId: str) -> Dict[str, Dict[str ``error``, so a successful retry hides an old failure. Any other status (`pending`, `cancelled`, ...) is kept verbatim. """ - from .datamodelFeatureTrustee import TrusteeAccountingSync syncRecords = interface.db.getRecordset( TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId} @@ -1290,7 +1288,6 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context """Handle mode=filterValues and mode=ids for trustee positions.""" from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from .datamodelFeatureTrustee import TrusteePositionView interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) if mode == "filterValues": if not column: @@ -1507,7 +1504,6 @@ def delete_accounting_config( """Remove the accounting integration for this instance.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import TrusteeAccountingConfig records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId}) for r in records: interface.db.recordDelete(TrusteeAccountingConfig, r.get("id")) @@ -1602,7 +1598,6 @@ def get_sync_status( """Get sync status of all positions for this instance.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import TrusteeAccountingSync records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId}) return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]} @@ -1618,7 +1613,6 @@ def get_position_sync_status( """Get sync status for a specific position.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import TrusteeAccountingSync records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId}) return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]} @@ -1776,7 +1770,6 @@ def _serializeRoleForApi(role) -> Dict[str, Any]: here (same pattern as ``getQuickActions``). Without this the React tree crashes with "Objects are not valid as a React child". """ - from modules.shared.i18nRegistry import resolveText payload = role.model_dump() payload["description"] = resolveText(payload.get("description")) return payload diff --git a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py index 240809c1..9574b7c6 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py @@ -269,7 +269,6 @@ async def _extractWithAi( ) -> Dict[str, Any]: """3-step extraction: (1a) OCR/text via Vision AI, (1b) classify text, (2) structure by type.""" await self.services.ai.ensureAiObjectsInitialized() - from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference docList = DocumentReferenceList( references=[DocumentItemReference(documentId=chatDocumentId, fileName=fileName)] diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index c6d143dd..18cf0da8 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -917,7 +917,6 @@ async def _runWorkspaceAgent( messagePersisted = False _toolSet = _cfg.get("toolSet", "core") _agentCfg = _cfg.get("agentConfig") - from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig agentCfgDict = dict(_agentCfg) if isinstance(_agentCfg, dict) else {} try: diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index a1800648..77bc1f5e 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -465,7 +465,6 @@ class AiObjects: toolChoice: Any = None, ) -> AsyncGenerator[Union[str, AiCallResponse], None]: """Stream a model call. Yields str deltas, then final AiCallResponse with billing.""" - from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse inputBytes = sum(len(str(m.get("content", "")).encode("utf-8")) for m in messages) startTime = time.time() @@ -537,7 +536,6 @@ class AiObjects: Returns: AiCallResponse with metadata["embeddings"] containing the vectors. """ - from modules.aicore.aicoreBase import ContextLengthExceededException if options is None: options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index a3dda3ba..250190fb 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -220,7 +220,6 @@ class AppObjects: tableName = modelClass.__name__ # Use buildDataObjectKey for semantic namespace lookup - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName) permissions = self.rbac.getUserPermissions( self.currentUser, @@ -1122,8 +1121,6 @@ class AppObjects: def _deleteUserReferencedData(self, userId: str) -> None: """Deletes all data associated with a user (full cascade).""" try: - from modules.datamodels.datamodelNotification import UserNotification - from modules.datamodels.datamodelInvitation import Invitation # 1. FeatureAccess + FeatureAccessRole accesses = self.db.getRecordset(FeatureAccess, recordFilter={"userId": userId}) @@ -1560,7 +1557,6 @@ class AppObjects: # Copy system template roles to new mandate (admin, user, viewer + AccessRules) try: - from modules.interfaces.interfaceRbac import copySystemRolesToMandate copiedCount = copySystemRolesToMandate(self.db, mandateId) logger.info(f"Copied {copiedCount} system roles to new mandate {mandateId}") except Exception as e: @@ -1576,8 +1572,6 @@ class AppObjects: ``mandateLabel`` is the display name (Voller Name); a unique slug ``name`` (Kurzzeichen) is derived. """ from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS - from modules.datamodels.datamodelFeatures import FeatureInstance - from modules.interfaces.interfaceRbac import copySystemRolesToMandate from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.shared.featureDiscovery import loadFeatureMainModules plan = BUILTIN_PLANS.get(planKey) @@ -1847,7 +1841,6 @@ class AppObjects: raise PermissionError(f"No permission to delete mandate {mandateId}") if not force: - from modules.shared.timeUtils import getUtcTimestamp self.db.recordModify(Mandate, mandateId, {"enabled": False, "deletedAt": getUtcTimestamp()}) logger.info(f"Soft-deleted mandate {mandateId} (30-day retention)") return True @@ -1858,8 +1851,6 @@ class AppObjects: from modules.datamodels.datamodelFiles import FileItem from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk - from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) @@ -1983,7 +1974,6 @@ class AppObjects: # 3b. Billing data cascade handled by onMandateDelete lifecycle hook (interfaceDbBilling) # 3c. Delete Invitations for this mandate - from modules.datamodels.datamodelInvitation import Invitation invitations = self.db.getRecordset(Invitation, recordFilter={"mandateId": mandateId}) for inv in invitations: self.db.recordDelete(Invitation, inv.get("id")) @@ -1991,7 +1981,6 @@ class AppObjects: logger.info(f"Cascade: deleted {len(invitations)} Invitations for mandate {mandateId}") # 4. Delete mandate-level Roles - from modules.datamodels.datamodelRbac import Role, AccessRule roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId}) for role in roles: rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")}) @@ -3961,7 +3950,6 @@ class AppObjects: def getTableListViews(self, contextKey: str) -> list: """Return all saved views for the current user and contextKey.""" - from modules.datamodels.datamodelPagination import TableListView try: rows = self.db.getRecordset( TableListView, @@ -3980,7 +3968,6 @@ class AppObjects: def getTableListView(self, contextKey: str, viewKey: str): """Return one view by viewKey or None if not found.""" - from modules.datamodels.datamodelPagination import TableListView try: rows = self.db.getRecordset( TableListView, @@ -3996,8 +3983,6 @@ class AppObjects: def createTableListView(self, contextKey: str, viewKey: str, displayName: str, config: dict): """Create a new view. Raises ValueError if viewKey already exists for this context.""" - from modules.datamodels.datamodelPagination import TableListView - from modules.shared.timeUtils import getUtcTimestamp if self.getTableListView(contextKey=contextKey, viewKey=viewKey) is not None: raise ValueError(f"View '{viewKey}' already exists for context '{contextKey}'") data = { @@ -4018,8 +4003,6 @@ class AppObjects: def updateTableListView(self, viewId: str, updates: dict): """Update an existing view by its primary key id.""" - from modules.datamodels.datamodelPagination import TableListView - from modules.shared.timeUtils import getUtcTimestamp try: updates = {**updates, "updatedAt": getUtcTimestamp()} self.db.recordModify(TableListView, viewId, updates) @@ -4034,7 +4017,6 @@ class AppObjects: def deleteTableListView(self, viewId: str) -> bool: """Delete a view by primary key id. Returns True on success.""" - from modules.datamodels.datamodelPagination import TableListView try: self.db.recordDelete(TableListView, viewId) return True diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 94600a0c..158f6d86 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -1654,8 +1654,6 @@ class BillingObjects: `amount` column. Resolves matching mandate/user IDs via the app DB first, then builds a single SQL query with OR-combined conditions. """ - from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields - from modules.datamodels.datamodelUam import UserInDB from modules.interfaces.interfaceDbApp import getInterface as getAppInterface table = BillingTransaction.__name__ diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 71ccb774..4bbba04b 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -393,7 +393,6 @@ class ChatObjects: tableName = modelClass.__name__ # Use buildDataObjectKey for semantic namespace lookup - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName) permissions = self.rbac.getUserPermissions( self.currentUser, @@ -826,7 +825,6 @@ class ChatObjects: if not effectiveMandateId: # Fall back to Root mandate (first mandate in system) try: - from modules.datamodels.datamodelUam import Mandate from modules.security.rootAccess import getRootDbAppConnector dbAppConn = getRootDbAppConnector() allMandates = dbAppConn.getRecordset(Mandate) diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index 9c5a9bd3..dfafebe0 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -741,7 +741,6 @@ def migrateVectorDimensions(): If it differs from the target, nulls existing embeddings and alters the column type. Safe to call on every startup — skips when dimensions already match or table doesn't exist. """ - from modules.datamodels.datamodelKnowledge import KNOWLEDGE_EMBEDDING_DIMENSIONS targetDim = KNOWLEDGE_EMBEDDING_DIMENSIONS interface = getInterface() diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 93e2d1c3..66f6485c 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -317,7 +317,6 @@ class ComponentObjects: return False tableName = modelClass.__name__ - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName) permissions = self.rbac.getUserPermissions( self.currentUser, @@ -1066,7 +1065,6 @@ class ComponentObjects: Owners always can. Non-owners need RBAC ALL level.""" if self._isFolderOwner(folder): return - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey("FileFolder") permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, @@ -1207,7 +1205,6 @@ class ComponentObjects: self._requireFolderWriteAccess(folder, folderId, "update") if scope == "global": - from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey("FileFolder") permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, @@ -1387,8 +1384,6 @@ class ComponentObjects: Owners always can. Non-owners need RBAC ALL level.""" if self._isFileOwner(file): return - from modules.interfaces.interfaceRbac import buildDataObjectKey - from modules.datamodels.datamodelRbac import AccessRuleContext objectKey = buildDataObjectKey("FileItem") permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index ebcf8c56..22047ab3 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -379,7 +379,6 @@ def getRecordsetWithRBAC( # Handle JSONB fields and ensure numeric types are correct # Import the helper function from connector module - from modules.connectors.connectorDbPostgre import getModelFields fields = getModelFields(modelClass) for record in records: for fieldName, fieldType in fields.items(): @@ -511,7 +510,6 @@ def getRecordsetPaginatedWithRBAC( whereValues.append(value) if pagination and pagination.filters: - from modules.connectors.connectorDbPostgre import getModelFields fields = getModelFields(modelClass) validColumns = set(fields.keys()) for key, val in pagination.filters.items(): @@ -545,7 +543,6 @@ def getRecordsetPaginatedWithRBAC( orderParts: List[str] = [] if pagination and pagination.sort: - from modules.connectors.connectorDbPostgre import getModelFields validColumns = set(getModelFields(modelClass).keys()) for sf in pagination.sort: if sf.field in validColumns: @@ -569,7 +566,6 @@ def getRecordsetPaginatedWithRBAC( cursor.execute(dataSql, whereValues) records = [dict(row) for row in cursor.fetchall()] - from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields fields = getModelFields(modelClass) for record in records: parseRecordFields(record, fields, f"table {table}") @@ -625,7 +621,6 @@ def getDistinctColumnValuesWithRBAC( if not connector._ensureTableExists(modelClass): return [] - from modules.connectors.connectorDbPostgre import getModelFields fields = getModelFields(modelClass) if column not in fields: return [] @@ -949,7 +944,6 @@ def buildRbacWhereClause( # Fall back to Root mandate (first mandate in system) for GROUP access # This allows system-level tables to be accessed without explicit mandate context try: - from modules.datamodels.datamodelUam import Mandate dbApp = getRootDbAppConnector() allMandates = dbApp.getRecordset(Mandate) if allMandates: diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py index e7c188c5..84b4cdd0 100644 --- a/modules/interfaces/interfaceTableHelpers.py +++ b/modules/interfaces/interfaceTableHelpers.py @@ -85,7 +85,6 @@ def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional Returns the (mutated) params, or a new minimal PaginationParams when params is None (so callers always get a valid object). """ - from modules.datamodels.datamodelPagination import SortField if not viewConfig: return params @@ -264,7 +263,6 @@ def buildGroupLayout( ------- (page_items, GroupLayout | None) """ - from modules.datamodels.datamodelPagination import GroupBand, GroupLayout if not groupByLevels: offset = (page - 1) * pageSize diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index e8daa385..ec6d7878 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -473,7 +473,6 @@ def list_feature_instances( items = [inst.model_dump() for inst in instances] from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.datamodels.datamodelFeatures import FeatureInstance enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db) if mode == "filterValues": diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 83aaef00..cd91f36c 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -40,7 +40,6 @@ def _getAdminMandateIds(context: RequestContext) -> List[str]: """Get mandate IDs where the user has an admin role.""" mandateIds = [] try: - from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() userMandates = rootInterface.getUserMandates(str(context.user.id)) for um in userMandates: @@ -64,7 +63,6 @@ def _getAdminMandateIds(context: RequestContext) -> List[str]: def _isRoleInAdminMandates(roleId: str, adminMandateIds: List[str]) -> bool: """Check if a role belongs to one of the admin's mandates.""" try: - from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() role = rootInterface.getRole(roleId) if not role: @@ -1405,7 +1403,6 @@ def cleanup_duplicate_access_rules( # Phase 2: Fix template role assignments # UserMandateRole should reference mandate-instance roles, not templates # ===================================================================== - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole allUserMandateRoles = rootInterface.db.getRecordset(UserMandateRole) templateFixDetails = [] diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index ca95f7da..1d1441c4 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -756,7 +756,6 @@ def createOrUpdateSettings( return result or existingSettings return existingSettings else: - from modules.datamodels.datamodelBilling import BillingSettings newSettings = BillingSettings( mandateId=targetMandateId, @@ -821,7 +820,6 @@ def addCredit( if creditRequest.amount == 0: raise HTTPException(status_code=400, detail=routeApiMsg("Amount must not be zero")) - from modules.datamodels.datamodelBilling import BillingTransaction isDeduction = creditRequest.amount < 0 txType = TransactionTypeEnum.DEBIT if isDeduction else TransactionTypeEnum.CREDIT diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 2d425d25..b2830a40 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -161,7 +161,6 @@ async def get_connections( from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) - from modules.datamodels.datamodelPagination import AppliedViewMeta CONTEXT_KEY = "connections" @@ -782,7 +781,6 @@ async def _updateKnowledgeConsent( if not connection: raise HTTPException(status_code=404, detail=routeApiMsg("Connection not found")) - from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() rootIf.db.recordModify(UserConnection, connectionId, {"knowledgeIngestionEnabled": enabled}) @@ -861,7 +859,6 @@ def _updateKnowledgePreferences( cleaned = {k: v for k, v in preferences.items() if k in _ALLOWED_KEYS} merged = {**existing, **cleaned, "schemaVersion": 1} - from modules.interfaces.interfaceDbApp import getRootInterface getRootInterface().db.recordModify(UserConnection, connectionId, {"knowledgePreferences": merged}) logger.info("Knowledge preferences updated for connection %s", connectionId) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 41625d26..cdb26f31 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -738,7 +738,6 @@ def get_files( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) import modules.interfaces.interfaceDbApp as _appIface - from modules.datamodels.datamodelPagination import AppliedViewMeta managementInterface = interfaceDbManagement.getInterface( currentUser, @@ -1202,7 +1201,6 @@ def bulk_set_neutralize( managementInterface.updateFile(fid, {"neutralize": neutralize}) if not neutralize: try: - from modules.interfaces import interfaceDbKnowledge kIface = interfaceDbKnowledge.getInterface(currentUser) kIface.purgeFileKnowledge(fid) except Exception as ke: diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 164d4233..6f11493c 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -55,7 +55,6 @@ def get_prompts( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - from modules.datamodels.datamodelPagination import AppliedViewMeta CONTEXT_KEY = "prompts" diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py index 5b13d592..7a2101ec 100644 --- a/modules/routes/routeMfa.py +++ b/modules/routes/routeMfa.py @@ -215,7 +215,6 @@ def mfaVerify( jti = jwt.decode(accessToken, SECRET_KEY, algorithms=[ALGORITHM]).get("jti") - from modules.interfaces.interfaceDbApp import getInterface user = User.model_validate(userRecord) userInterface = getInterface(user) dbToken = Token( diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index ef63fc1a..30adfef7 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -411,7 +411,6 @@ def _handleInvitationAction( ) -> str: """Handle accept/decline actions for invitation notifications.""" from modules.datamodels.datamodelInvitation import Invitation - from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelMembership import UserMandate invitationId = notification.referenceId diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index 82348d9a..9b2eee2e 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -485,7 +485,6 @@ def _getInventoryPlatform( from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService - from modules.datamodels.datamodelUam import UserConnection rootIf = getRootInterface() knowledgeIf = getKnowledgeInterface(None) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 3107a2b8..177cfaad 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -70,7 +70,6 @@ def buildAuthEmailHtml( operatorLine = "" try: - from modules.shared.configuration import APP_CONFIG parts = [p for p in [ APP_CONFIG.get("Operator_CompanyName", ""), APP_CONFIG.get("Operator_Address", ""), @@ -194,7 +193,6 @@ def _ensureHomeMandate(rootInterface, user) -> None: return try: - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf appIf = _getRootIf() normalizedEmail = (user.email or "").strip().lower() if user.email else None pendingByUsername = appIf.getInvitationsByTargetUsername(user.username) @@ -1058,7 +1056,6 @@ def _getNeutralizationMappings( ): """List the current user's neutralization placeholder mappings.""" userId = str(context.user.id) - from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId}) @@ -1074,7 +1071,6 @@ def _deleteNeutralizationMapping( ): """Delete a specific neutralization mapping owned by the current user.""" userId = str(context.user.id) - from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId}) diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 1ea2ba1b..c4e2fa84 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -64,7 +64,6 @@ async def _listWorkflows( mandateId: Optional[str] = Query(default=None), ): from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) @@ -174,7 +173,6 @@ async def _listRuns( workflowId: Optional[str] = Query(default=None), ): from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) @@ -476,17 +474,14 @@ def _listTemplates( templates = iface.getTemplates(scope=scope) from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db) if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory return handleFilterValuesInMemory(templates, column, pagination) if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(templates, pagination) paginationParams = None @@ -1328,7 +1323,6 @@ def _getRunDetail( if tid: try: from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface labelMap = resolveInstanceLabels(_getRootIface().db, [tid]) targetInstanceLabel = labelMap.get(tid) except Exception: @@ -1425,7 +1419,6 @@ def _startEmailPollerIfNeeded(result: dict) -> None: if not isinstance(result, dict) or result.get("waitReason") != "email": return try: - from modules.interfaces.interfaceDbApp import getRootInterface from modules.workflowAutomation.scheduler.emailPoller import ensureRunning root = getRootInterface() eventUser = root.getUserByUsername("event") if root else None diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 81fc7f29..cbb576b2 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -425,7 +425,6 @@ class AgentService: activeToolNames.update(tb.tools) from modules.serviceCenter.services.serviceAgent.externalToolRegistry import getExternalTools - from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition for tb in activeToolboxes: extDefs = getExternalTools(tb.id) if not extDefs: @@ -459,7 +458,6 @@ class AgentService: from modules.serviceCenter.services.serviceAgent.toolboxRegistry import ( getToolboxRegistry, buildRequestToolboxDefinition, REQUEST_TOOLBOX_TOOL_NAME, ) - from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult tbRegistry = getToolboxRegistry() allIds = [tb.id for tb in tbRegistry.getAllToolboxes()] @@ -488,7 +486,6 @@ class AgentService: activatedCount += 1 continue try: - from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools registerCoreTools(registry, self.services) if registry.isValidTool(toolName): activatedCount += 1 @@ -499,9 +496,6 @@ class AgentService: try: from modules.workflows.processing.shared.methodDiscovery import discoverMethods from modules.workflows.processing.core.actionExecutor import ActionExecutor - from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ( - ActionToolAdapter, - ) discoverMethods(self.services) adapter = ActionToolAdapter(ActionExecutor(self.services)) @@ -622,7 +616,6 @@ class AgentService: def _createPersistRoundMemoryFn(self, workflowId: str): """Create callback that persists RoundMemory entries after tool execution.""" - from modules.serviceCenter.services.serviceAgent.agentLoop import classifyToolResult from modules.datamodels.datamodelKnowledge import RoundMemory async def _persistRoundMemory( diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index 79389b21..75fc77de 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -335,7 +335,6 @@ class AiService: Returns: AiCallResponse with content as JSON string (SpeechTeamsResponse format) """ - from modules.datamodels.datamodelAi import AiCallResponse, AiModelCall, AiCallOptions, PriorityEnum startTime = time.time() @@ -637,7 +636,6 @@ detectedIntent-Werte: try: from modules.aicore.aicoreModelRegistry import modelRegistry from modules.aicore.aicoreModelSelector import modelSelector as _modSel - from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum _models = modelRegistry.getAvailableModels() _providers = self._calculateEffectiveProviders() diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 44e42583..95a9248a 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -615,7 +615,6 @@ class ChatService: def getUserVoicePreferences(self, userId: str, mandateId: str = None) -> Optional[Dict[str, Any]]: """Get TTS voice preferences for a user, resolved by mandate scope.""" - from modules.datamodels.datamodelUam import UserVoicePreferences try: prefRecords = self.interfaceDbApp.db.getRecordset( UserVoicePreferences, recordFilter={"userId": userId} @@ -842,7 +841,6 @@ class ChatService: """Create an ActionItem record in the chat DB. Encapsulates low-level _separateObjectFields + db.recordCreate so callers never need direct interfaceDbChat access.""" - from modules.datamodels.datamodelChat import ActionItem simpleFields, _objectFields = self.interfaceDbChat._separateObjectFields(ActionItem, actionData) return self.interfaceDbChat.db.recordCreate(ActionItem, simpleFields) diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py index 3e33db52..b71e6d65 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py @@ -204,7 +204,6 @@ def _addFilePart( entryPath = f"{containerPath}/{fileName}" if containerPath else fileName detectedMime = _detectMimeType(fileName) - from ..subRegistry import getExtractorRegistry registry = getExtractorRegistry() extractor = registry.resolve(detectedMime, fileName) diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py index 6180f5d1..20a1fbd4 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py @@ -255,7 +255,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth guessedMime, _ = mimetypes.guess_type(attachName) detectedMime = guessedMime or "application/octet-stream" - from ..subRegistry import getExtractorRegistry registry = getExtractorRegistry() extractor = registry.resolve(detectedMime, attachName) diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py index 0f81fce0..fc11d6fe 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py @@ -141,7 +141,6 @@ def _walkFolder( guessedMime, _ = mimetypes.guess_type(entry.name) detectedMime = guessedMime or "application/octet-stream" - from ..subRegistry import ExtractorRegistry registry = ExtractorRegistry() extractor = registry.resolve(detectedMime, entry.name) diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py index 7072ecbb..8f5be299 100644 --- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py +++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py @@ -50,8 +50,6 @@ class Extractor: precomputedParts: Optional[List[ContentPart]] = None, ) -> "UdmDocument": """Build UDM from extracted parts (default: heuristic grouping). Override for format-specific trees.""" - from modules.datamodels.datamodelUdm import contentPartsToUdm, mimeToUdmSourceType - from modules.datamodels.datamodelExtraction import ContentExtracted from .subUtils import makeId parts = precomputedParts if precomputedParts is not None else self.extract(fileBytes, context) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index d1fe3b20..55fa8c99 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -230,7 +230,6 @@ class RendererPdf(BaseRenderer): # memory simultaneously. Collected here, deleted after the build. self._tempImageFiles = [] try: - from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle self._unifiedStyle = unifiedStyle or resolveStyle(None) styles = self._convertUnifiedStyleToInternal(self._unifiedStyle) for level in range(1, 7): diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py index 1547086f..4a1db42c 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py @@ -90,7 +90,6 @@ class RendererPptx(BaseRenderer): from pptx.dml.color import RGBColor if not style: - from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle style = resolveStyle(None) internalStyle = self._convertUnifiedStyleToInternal(style) styles = internalStyle diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py index aaa5d022..86846229 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py @@ -137,7 +137,6 @@ class RendererXlsx(BaseRenderer): self.services.utils.debugLogToFile(f"EXCEL JSON CONTENT KEYS: {list(jsonContent.keys()) if isinstance(jsonContent, dict) else 'Not a dict'}", "EXCEL_RENDERER") if not style: - from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle style = resolveStyle(None) self._unifiedStyle = style diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py index 8e2f5935..edb347f1 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py @@ -26,6 +26,7 @@ from typing import Any, Callable, Dict, List, Optional from modules.serviceCenter.services.serviceKnowledge.subTextClean import cleanEmailBody from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( WalkerTimeout, + extractWithTimeout as _extractWithTimeout, ingestWithTimeout, logItemStart, ) @@ -564,10 +565,6 @@ async def _ingestAttachments( attLabel = f"{messageId}/att:{stub['attachmentId']}/{fileName}" logItemStart("gmail-attachment", attLabel, sizeBytes=stub.get("size") or None, mime=mimeType) - from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( - extractWithTimeout as _extractWithTimeout, - ) - def _runAttExtraction(): return runExtraction( extractorRegistry, chunkerRegistry, diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 439d9a5b..8714f660 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -414,7 +414,6 @@ class SubscriptionService: mandateLabel = mandateId try: - from modules.datamodels.datamodelUam import Mandate from modules.security.rootAccess import getRootDbAppConnector appDb = getRootDbAppConnector() rows = appDb.getRecordset(Mandate, recordFilter={"id": mandateId}) @@ -937,7 +936,6 @@ def _buildInvoiceSummaryHtml( ) -> str: """Build an HTML invoice summary block for inclusion in the activation email.""" import html as htmlmod - from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface subInterface = getSubRootInterface() userCount = subInterface.countActiveUsers(mandateId) diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py index 5a9ec8fd..8b1ec19e 100644 --- a/modules/system/databaseHealth.py +++ b/modules/system/databaseHealth.py @@ -805,7 +805,6 @@ def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]: Returns a list of dicts: {db, table, rowCount, sizeBytes}. """ from modules.datamodels.datamodelBase import MODEL_REGISTRY - from modules.dbHelpers.fkRegistry import ensureModelsLoaded ensureModelsLoaded() registeredDbs = getRegisteredDatabases() @@ -854,7 +853,6 @@ def _dropLegacyTable(dbName: str, tableName: str) -> dict: Raises ValueError if the table is model-backed (safety guard). """ from modules.datamodels.datamodelBase import MODEL_REGISTRY - from modules.dbHelpers.fkRegistry import ensureModelsLoaded ensureModelsLoaded() if tableName in MODEL_REGISTRY: diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index aa472f15..7e5cb30d 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -305,7 +305,6 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool: """True iff the port schema declares ``carriesConnectionProvenance`` in the catalog.""" - from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(outputSchema) return bool(getattr(schema, "carriesConnectionProvenance", False)) diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py index 9f28f274..a2121b7f 100644 --- a/modules/workflowAutomation/helpers.py +++ b/modules/workflowAutomation/helpers.py @@ -203,7 +203,6 @@ def _validateWorkflowAccess( if action == "execute": targetInstanceId = workflow.get("targetFeatureInstanceId") if targetInstanceId: - from modules.interfaces.interfaceDbApp import getRootInterface access = getRootInterface().getFeatureAccess(userId, targetInstanceId) if access and access.get("enabled"): return @@ -582,7 +581,6 @@ def _getWorkflowsJoinedPaginated( paginationParams: PaginationParams, ) -> dict: """SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count).""" - from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields wfFields = getModelFields(AutoWorkflow) whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit( diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py index 086530c7..e07673c6 100644 --- a/modules/workflowAutomation/mainWorkflowAutomation.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -291,7 +291,6 @@ def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, template """Create workflow instances from template definitions when a feature instance is created.""" from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.security.rootAccess import getRootUser - from modules.shared.i18nRegistry import resolveText rootUser = getRootUser() waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId) diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 77adc40f..d943d7da 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -37,7 +37,6 @@ def _action_docs_to_content_parts(services, docs: List[Any]) -> List[ContentPart """Extract content from ActionDocument-like objects in memory (no persistence). Decodes base64, runs extraction pipeline, returns ContentParts for AI. """ - from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy all_parts = [] extraction = services.extraction @@ -78,7 +77,6 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar references, not chat message attachments. In the agent/chat context, ``DocumentItemReference`` holds ChatDocument IDs that must be resolved via ``getChatDocumentsFromDocumentList`` instead.""" - from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy extraction = services.extraction if not extraction: diff --git a/scripts/script_analyze_platform_module_graph.py b/scripts/script_analyze_platform_module_graph.py new file mode 100644 index 00000000..8e8866c4 --- /dev/null +++ b/scripts/script_analyze_platform_module_graph.py @@ -0,0 +1,568 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. +""" +Deep platform-core module import graph analysis. + +Output: local/notes/refernce-analysis/import-analysis-platform-modules.md + +Usage: + python platform-core/scripts/script_analyze_platform_module_graph.py +""" + +from __future__ import annotations + +import ast +import os +import sys +from collections import defaultdict +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from script_analyze_porta_imports import ( # noqa: E402 + OUTPUT_ROOT, + PLATFORM_ROOT, + SKIP_DIR_NAMES, + _collectPlatformModules, + _getPlatformContainer, + _platformModuleId, + _resolvePlatformImportTarget, + _resolvePlatformRelativeImport, + _writeText, +) + + +OUTPUT_FILE = OUTPUT_ROOT / "import-analysis-platform-modules.md" + +LAYER_ORDER = { + "shared": 0, + "datamodels": 1, + "connectors": 2, + "nodeCatalog": 2, + "dbHelpers": 3, + "interfaces": 4, + "system": 4, + "security": 4, + "auth": 4, + "aicore": 4, + "demoConfigs": 4, + "serviceCenter": 5, + "workflows": 5, + "workflowAutomation": 5, + "features.commcoach": 5, + "features.neutralization": 5, + "features.realEstate": 5, + "features.realestate": 5, + "features.redmine": 5, + "features.teamsbot": 5, + "features.trustee": 5, + "features.workspace": 5, + "routes": 6, + "app": 7, +} + + +@dataclass +class ScopedImport: + target: str + rawModule: str + position: str + scope: str + isInternal: bool + isStdLib: bool + + +def _shortModule(moduleId: str) -> str: + parts = moduleId.replace("platform-core.", "").split(".") + if len(parts) <= 3: + return ".".join(parts) + return ".".join(parts[-3:]) + + +LIFECYCLE_SCOPE_MARKERS = ( + "lifespan", + "onBootstrap", + "onStart", + "onStop", + "onInstanceCreate", + "onMandateDelete", + "registerFeature", + "preWarm", +) + + +def _layerOf(moduleId: str) -> Optional[int]: + container = _getPlatformContainer(moduleId) + if container is None: + return None + return LAYER_ORDER.get(container) + + +def _isStdLibModule(moduleName: str) -> bool: + root = moduleName.split(".")[0] + if root.startswith("_"): + return False + if root in sys.builtin_module_names: + return True + if hasattr(sys, "stdlib_module_names") and root in sys.stdlib_module_names: + return True + return False + + +class _DetailedImportVisitor(ast.NodeVisitor): + def __init__(self, filePath: Path): + self.filePath = filePath + self.imports: List[ScopedImport] = [] + self._scopeStack: List[str] = [] + + @property + def _currentScope(self) -> str: + return self._scopeStack[-1] if self._scopeStack else "" + + def _position(self) -> str: + return "code" if self._scopeStack else "header" + + def _add(self, rawModule: str, resolved: str, isInternal: bool) -> None: + self.imports.append( + ScopedImport( + target=resolved, + rawModule=rawModule, + position=self._position(), + scope=self._currentScope, + isInternal=isInternal, + isStdLib=_isStdLibModule(rawModule) if not isInternal else False, + ) + ) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._scopeStack.append(f"function {node.name}") + self.generic_visit(node) + self._scopeStack.pop() + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._scopeStack.append(f"function {node.name}") + self.generic_visit(node) + self._scopeStack.pop() + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._scopeStack.append(f"class {node.name}") + self.generic_visit(node) + self._scopeStack.pop() + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + resolved, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name) + self._add(alias.name, resolved, isInternal) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.level > 0: + resolved = _resolvePlatformRelativeImport(self.filePath, node) + if resolved: + suffix = node.module or "" + raw = ("." * node.level) + suffix + self._add(raw, resolved, True) + return + if not node.module: + return + resolved, isInternal = _resolvePlatformImportTarget(self.filePath, node.module) + self._add(node.module, resolved, isInternal) + + +def _collectDetailedImports() -> Dict[str, List[ScopedImport]]: + byModule: Dict[str, List[ScopedImport]] = {} + pyFiles: List[Path] = [] + appFile = PLATFORM_ROOT / "app.py" + if appFile.exists(): + pyFiles.append(appFile) + for root, dirs, files in os.walk(PLATFORM_ROOT / "modules"): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES] + for fileName in files: + if fileName.endswith(".py"): + pyFiles.append(Path(root) / fileName) + + for filePath in pyFiles: + moduleId = _platformModuleId(filePath) + if _getPlatformContainer(moduleId) is None: + continue + try: + tree = ast.parse(filePath.read_text(encoding="utf-8"), filename=str(filePath)) + except (SyntaxError, UnicodeDecodeError): + continue + visitor = _DetailedImportVisitor(filePath) + visitor.visit(tree) + byModule[moduleId] = visitor.imports + return byModule + + +def _internalGraph(importsByModule: Dict[str, List[ScopedImport]]) -> Dict[str, Set[str]]: + graph: Dict[str, Set[str]] = defaultdict(set) + for source, items in importsByModule.items(): + for item in items: + if item.isInternal and item.target.startswith("platform-core."): + graph[source].add(item.target) + return dict(graph) + + +def _mutualPairs( + graph: Dict[str, Set[str]], + moduleFilter: Optional[Set[str]] = None, +) -> List[Tuple[str, str]]: + pairs: List[Tuple[str, str]] = [] + seen: Set[Tuple[str, str]] = set() + sources = moduleFilter if moduleFilter is not None else set(graph.keys()) + for source in sorted(sources): + for target in graph.get(source, set()): + if moduleFilter is not None and target not in moduleFilter: + continue + if target not in graph or source not in graph[target]: + continue + key = tuple(sorted((source, target))) + if key not in seen: + seen.add(key) + pairs.append(key) + return pairs + + +def _tarjanScc(graph: Dict[str, Set[str]]) -> List[List[str]]: + index = 0 + stack: List[str] = [] + onStack: Set[str] = set() + indices: Dict[str, int] = {} + lowLink: Dict[str, int] = {} + result: List[List[str]] = [] + + nodes = set(graph.keys()) + for targets in graph.values(): + nodes.update(targets) + + def strongConnect(node: str) -> None: + nonlocal index + indices[node] = index + lowLink[node] = index + index += 1 + stack.append(node) + onStack.add(node) + + for neighbor in graph.get(node, set()): + if neighbor not in indices: + strongConnect(neighbor) + lowLink[node] = min(lowLink[node], lowLink[neighbor]) + elif neighbor in onStack: + lowLink[node] = min(lowLink[node], indices[neighbor]) + + if lowLink[node] == indices[node]: + component: List[str] = [] + while True: + w = stack.pop() + onStack.remove(w) + component.append(w) + if w == node: + break + if len(component) > 1 or (len(component) == 1 and component[0] in graph.get(component[0], set())): + result.append(sorted(component)) + + for node in sorted(nodes): + if node not in indices: + strongConnect(node) + return sorted(result, key=lambda c: (len(c), c[0]), reverse=True) + + +def _canReach(graph: Dict[str, Set[str]], start: str, goal: str, skipEdge: Optional[Tuple[str, str]] = None) -> bool: + visited: Set[str] = set() + + def dfs(node: str) -> bool: + if node == goal: + return True + if node in visited: + return False + visited.add(node) + for nxt in graph.get(node, set()): + if skipEdge and node == skipEdge[0] and nxt == skipEdge[1]: + continue + if dfs(nxt): + return True + return False + + return dfs(start) + + +def _assessMutualPair(a: str, b: str) -> str: + containerA = _getPlatformContainer(a) + containerB = _getPlatformContainer(b) + layerA = _layerOf(a) + layerB = _layerOf(b) + sameContainer = containerA == containerB + + if sameContainer: + if layerA is not None and layerA >= 5: + return "Prüfen — Feature/Service-interner Gegenimport; oft Lazy-Import-Workaround, Zyklus im Container." + return "Prüfen — gegenseitiger Import im gleichen Container; meist absichtlicher Lazy-Import gegen Zyklus." + + if layerA is not None and layerB is not None: + if layerA < layerB and layerB < layerA: + pass + upward = (layerA > layerB and layerB is not None) or (layerB > layerA and layerA is not None) + if upward: + return "Refactor-Kandidat — untere Schicht importiert obere und umgekehrt (Layer-Verletzung)." + return "Refactor-Kandidat — Cross-Container-Gegenimport; Layer-Grenze prüfen." + + +def _assessCycle(component: List[str]) -> str: + if len(component) == 1: + return "OK — Package-Reexport/Self-Import (__init__ ↔ Submodul); typisch für Barrel-Module." + containers = {_getPlatformContainer(m) for m in component} + containers.discard(None) + layers = [layer for m in component if (layer := _layerOf(m)) is not None] + if len(containers) == 1: + container = next(iter(containers)) + if LAYER_ORDER.get(container or "", 99) >= 5: + return "Prüfen — Zyklus innerhalb Feature/Service-Cluster; oft bekanntes Deferred-Coupling." + return "Prüfen — Intra-Container-Loop; Lazy-Imports prüfen ob extrahierbar." + if layers and max(layers) - min(layers) >= 2: + return "Refactor-Kandidat — Loop über mehrere Layer/Container; Architektur-Grenze verletzt." + return "Prüfen — Cross-Container-Loop; Abhängigkeit entkoppeln oder Typ/Protocol extrahieren." + + +def _assessLazyStdLib(moduleId: str, item: ScopedImport) -> str: + heavy = {"json", "csv", "xml", "pickle", "sqlite3", "subprocess", "multiprocessing"} + root = item.rawModule.split(".")[0] + if root in heavy: + return "OK — schwere Stdlib lazy (Startup/optional)." + if "TYPE_CHECKING" in item.scope: + return "OK — typing-only Kontext." + return "Harmlos — Stdlib lazy in Code-Scope; kein Architektur-Risiko." + + +def _assessMovable(moduleId: str, item: ScopedImport, graph: Dict[str, Set[str]], headerTargets: Set[str]) -> str: + if item.target in headerTargets: + return "Redundant — bereits im Header importiert; Lazy-Import entfernen." + if any(marker in item.scope for marker in LIFECYCLE_SCOPE_MARKERS): + return "Beabsichtigt lazy — Startup/Lifecycle-Hook; nicht in Header verschieben." + if _canReach(graph, item.target, moduleId): + return "Muss lazy bleiben — Header-Import würde Zyklus erzeugen." + return "Verschiebbar — kann vermutlich in den Header." + + +def _renderMarkdown( + importsByModule: Dict[str, List[ScopedImport]], + graph: Dict[str, Set[str]], +) -> str: + modulesByContainer: Dict[str, Set[str]] = defaultdict(set) + for moduleId in importsByModule: + container = _getPlatformContainer(moduleId) + if container: + modulesByContainer[container].add(moduleId) + + lines = [ + "# Import-Analyse Platform — Modul-Graph", + "", + f"- **Generiert:** {date.today().isoformat()}", + "- **Script:** `platform-core/scripts/script_analyze_platform_module_graph.py`", + "- **Scope:** interne `modules.*`-Imports (inkl. lazy)", + "", + "## Legende Beurteilung", + "", + "| Stufe | Bedeutung |", + "|-------|-----------|", + "| OK / Harmlos | kein Handlungsbedarf |", + "| Verschiebbar | Lazy-Import kann vermutlich in Header |", + "| Redundant | doppelter Import (Header + Code) |", + "| Prüfen | bekannt möglich, bewusst prüfen |", + "| Beabsichtigt lazy | Startup/Lifecycle — nicht in Header |", + "| Muss lazy bleiben | Zyklusvermeidung |", + "| Refactor-Kandidat | Layer-/Architektur-Thema |", + "", + ] + + # --- Mutual pairs per container --- + lines.extend(["## Gegenseitige Modul-Imports (Paare)", ""]) + totalPairs = 0 + for container in sorted(modulesByContainer.keys()): + moduleSet = modulesByContainer[container] + pairs = _mutualPairs(graph, moduleSet) + if not pairs: + continue + totalPairs += len(pairs) + lines.append(f"### Container `{container}`") + lines.append("") + lines.append("| Modul A | Modul B | Beurteilung |") + lines.append("|---------|---------|-------------|") + for a, b in pairs: + lines.append( + f"| `{_shortModule(a)}` | `{_shortModule(b)}` | {_assessMutualPair(a, b)} |" + ) + lines.append("") + + crossPairs = [ + p for p in _mutualPairs(graph) + if _getPlatformContainer(p[0]) != _getPlatformContainer(p[1]) + ] + if crossPairs: + lines.extend(["### Cross-Container (gegenseitig)", ""]) + lines.append("| Modul A | Container A | Modul B | Container B | Beurteilung |") + lines.append("|---------|-------------|---------|-------------|-------------|") + for a, b in crossPairs: + lines.append( + f"| `{_shortModule(a)}` | `{_getPlatformContainer(a)}` | " + f"`{_shortModule(b)}` | `{_getPlatformContainer(b)}` | {_assessMutualPair(a, b)} |" + ) + lines.append("") + if totalPairs == 0 and not crossPairs: + lines.append("_Keine gegenseitigen Modul-Paare gefunden._") + lines.append("") + + # --- Cycles --- + sccList = _tarjanScc(graph) + lines.extend(["## Import-Loops (über mehrere Module)", ""]) + if not sccList: + lines.append("_Keine Strongly-Connected Components (>1 Knoten) gefunden._") + lines.append("") + else: + lines.append(f"**{len(sccList)} Loop-Gruppe(n)** (Tarjan SCC, nur interne Module).") + lines.append("") + for index, component in enumerate(sccList, start=1): + containers = sorted({c for m in component if (c := _getPlatformContainer(m))}) + lines.append(f"### Loop {index} — {len(component)} Module") + lines.append("") + lines.append(f"- **Container:** {', '.join(f'`{c}`' for c in containers)}") + lines.append(f"- **Beurteilung:** {_assessCycle(component)}") + lines.append("- **Module:**") + for moduleId in component: + lines.append(f" - `{moduleId}`") + if len(component) <= 8: + chainHint = " → ".join(_shortModule(m) for m in component) + f" → `{_shortModule(component[0])}`" + lines.append(f"- **Ring (Auszug):** {chainHint}") + lines.append("") + + # --- Lazy stdlib --- + lines.extend(["## Lazy Stdlib-Imports (in Code-Scope)", ""]) + stdlibRows: List[Tuple[str, str, str, str, str]] = [] + for moduleId, items in sorted(importsByModule.items()): + for item in items: + if item.position == "code" and item.isStdLib: + stdlibRows.append( + ( + _shortModule(moduleId), + _getPlatformContainer(moduleId) or "", + item.rawModule, + item.scope or "(class/function)", + _assessLazyStdLib(moduleId, item), + ) + ) + if stdlibRows: + lines.append("| Modul | Container | Import | Scope | Beurteilung |") + lines.append("|-------|-----------|--------|-------|-------------|") + for row in stdlibRows: + lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |") + lines.append("") + else: + lines.append("_Keine lazy Stdlib-Imports in Code-Scope._") + lines.append("") + + # --- Lazy internal movable --- + lines.extend(["## Lazy interne Imports — Header möglich?", ""]) + movableRows: List[Tuple[str, str, str, str, str]] = [] + intentionalRows: List[Tuple[str, str, str, str, str]] = [] + mustStayRows: List[Tuple[str, str, str, str, str]] = [] + redundantRows: List[Tuple[str, str, str, str, str]] = [] + + for moduleId, items in sorted(importsByModule.items()): + headerTargets = {i.target for i in items if i.position == "header" and i.isInternal} + for item in items: + if item.position != "code" or not item.isInternal: + continue + verdict = _assessMovable(moduleId, item, graph, headerTargets) + row = ( + _shortModule(moduleId), + _getPlatformContainer(moduleId) or "", + _shortModule(item.target), + item.scope or "(code)", + verdict, + ) + if verdict.startswith("Verschiebbar"): + movableRows.append(row) + elif verdict.startswith("Beabsichtigt"): + intentionalRows.append(row) + elif verdict.startswith("Redundant"): + redundantRows.append(row) + elif verdict.startswith("Muss lazy"): + mustStayRows.append(row) + + if intentionalRows: + lines.append("### Beabsichtigt lazy (Startup/Lifecycle)") + lines.append("") + lines.append(f"**{len(intentionalRows)}** Einträge — lazy in lifespan/onBootstrap/…; kein Refactor nötig.") + lines.append("") + + if movableRows: + lines.append("### Verschiebbar in Header") + lines.append("") + lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |") + lines.append("|-------|-----------|-------------|-------|-------------|") + for row in movableRows: + lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |") + lines.append("") + + if mustStayRows: + lines.append("### Muss lazy bleiben (Zyklus)") + lines.append("") + lines.append(f"**{len(mustStayRows)}** Einträge — Auszug (max. 40):") + lines.append("") + lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |") + lines.append("|-------|-----------|-------------|-------|-------------|") + for row in mustStayRows[:40]: + lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |") + if len(mustStayRows) > 40: + lines.append("") + lines.append(f"_… und {len(mustStayRows) - 40} weitere._") + lines.append("") + + if redundantRows: + lines.append("### Redundant (Header + Code)") + lines.append("") + lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |") + lines.append("|-------|-----------|-------------|-------|-------------|") + for row in redundantRows: + lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |") + lines.append("") + + if not movableRows and not mustStayRows and not redundantRows and not intentionalRows: + lines.append("_Keine lazy internen Imports gefunden._") + lines.append("") + + lines.extend( + [ + "## Kurzfassung", + "", + f"- Gegenseitige Modul-Paare (intra-container): **{totalPairs}**", + f"- Gegenseitige Modul-Paare (cross-container): **{len(crossPairs)}**", + f"- Import-Loop-Gruppen (SCC): **{len(sccList)}** (davon Self-Loop: **{sum(1 for c in sccList if len(c) == 1)}**)", + f"- Lazy Stdlib-Imports: **{len(stdlibRows)}**", + f"- Lazy intern / beabsichtigt (Lifecycle): **{len(intentionalRows)}**", + f"- Lazy intern / verschiebbar: **{len(movableRows)}**", + f"- Lazy intern / Zyklus (muss bleiben): **{len(mustStayRows)}**", + f"- Lazy intern / redundant: **{len(redundantRows)}**", + "", + ] + ) + return "\n".join(lines) + + +def main() -> None: + print("Collecting detailed platform imports...") + importsByModule = _collectDetailedImports() + graph = _internalGraph(importsByModule) + print(f" modules: {len(importsByModule)}") + print(f" internal edges: {sum(len(v) for v in graph.values())}") + + markdown = _renderMarkdown(importsByModule, graph) + _writeText(OUTPUT_FILE, markdown) + print(f"Written: {OUTPUT_FILE}") + + +if __name__ == "__main__": + main() diff --git a/scripts/script_analyze_porta_imports.py b/scripts/script_analyze_porta_imports.py new file mode 100644 index 00000000..c752d683 --- /dev/null +++ b/scripts/script_analyze_porta_imports.py @@ -0,0 +1,898 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. +""" +Analyze all imports (including lazy/dynamic) for PowerOn PORTA UI and platform-core. + +Outputs under local/notes/refernce-analysis/: + platform/modules/*.md one file per Python module + platform/containers/*.md aggregated stats per container + platform/container-network.drawio + ui/modules/*.md + ui/containers/*.md + ui/container-network.drawio + README.md + +Usage: + python platform-core/scripts/script_analyze_porta_imports.py +""" + +from __future__ import annotations + +import ast +import html +import math +import os +import re +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent +REPO_ROOT = PLATFORM_ROOT.parent +UI_ROOT = REPO_ROOT / "ui-nyla" +OUTPUT_ROOT = REPO_ROOT / "local" / "notes" / "refernce-analysis" + +SKIP_DIR_NAMES = { + "__pycache__", + "node_modules", + ".git", + "dist", + "build", + ".venv", + "venv", + ".tox", + ".mypy_cache", + ".pytest_cache", +} +UI_SKIP_GLOBS = ("**/*.test.ts", "**/*.test.tsx", "test/**") + + +@dataclass +class ImportRecord: + importedModule: str + position: str # "header" | "code" + isInternal: bool + sourceContainer: Optional[str] = None + targetContainer: Optional[str] = None + + +@dataclass +class ModuleAnalysis: + context: str # "platform" | "ui" + moduleId: str + filePath: Path + container: str + containerPath: str + imports: List[ImportRecord] = field(default_factory=list) + + +def _sanitizeFileName(value: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "_", value) + + +def _writeText(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Platform (Python) +# --------------------------------------------------------------------------- + +def _platformModuleId(filePath: Path) -> str: + rel = filePath.relative_to(PLATFORM_ROOT) + if filePath.name == "__init__.py": + parts = rel.parent.parts + else: + parts = rel.with_suffix("").parts + return "platform-core." + ".".join(parts) + + +def _platformContainerPath(container: str) -> str: + if container == "app": + return "platform-core/app.py" + if container.startswith("features."): + featureCode = container.split(".", 1)[1] + return f"platform-core/modules/features/{featureCode}" + return f"platform-core/modules/{container}" + + +def _getPlatformContainer(moduleId: str) -> Optional[str]: + if moduleId == "platform-core.app": + return "app" + + if not moduleId.startswith("platform-core."): + return None + + parts = moduleId.replace("platform-core.", "").split(".") + if not parts: + return "app" + + if parts[0] in ("tests", "scripts") or parts[0].startswith("script_"): + return None + if parts[0] != "modules" or len(parts) < 2: + return "app" + + container = parts[1] + if container == "features" and len(parts) > 2: + return f"features.{parts[2]}" + return container + + +def _resolvePlatformRelativeImport(currentFile: Path, importNode: ast.ImportFrom) -> Optional[str]: + dotCount = importNode.level + moduleSuffix = importNode.module or "" + currentDir = currentFile.parent + + baseDir = currentDir + for _ in range(dotCount - 1): + baseDir = baseDir.parent + + if moduleSuffix: + candidate = baseDir / Path(moduleSuffix.replace(".", os.sep)) + else: + candidate = baseDir + + pyFile = candidate.with_suffix(".py") + if pyFile.exists(): + return _platformModuleId(pyFile) + + initFile = candidate / "__init__.py" + if initFile.exists(): + return _platformModuleId(initFile) + + rel = candidate.relative_to(PLATFORM_ROOT) if candidate.is_relative_to(PLATFORM_ROOT) else None + if rel is None: + return None + return "platform-core." + ".".join(rel.with_suffix("").parts) + + +def _resolvePlatformImportTarget(currentFile: Path, importedName: str) -> Tuple[str, bool]: + if importedName.startswith("."): + return importedName, False + + if importedName.startswith("modules."): + parts = importedName.split(".") + checkPath = PLATFORM_ROOT + for part in parts: + checkPath = checkPath / part + if checkPath.with_suffix(".py").exists(): + return _platformModuleId(checkPath.with_suffix(".py")), True + if checkPath.is_dir() and (checkPath / "__init__.py").exists(): + return _platformModuleId(checkPath / "__init__.py"), True + return f"platform-core.{importedName.replace('.', '.')}", True + + return importedName, False + + +class _PythonImportVisitor(ast.NodeVisitor): + def __init__(self, filePath: Path): + self.filePath = filePath + self.imports: List[ImportRecord] = [] + self._inCodeScope = False + + def _addImport(self, importedModule: str, isInternal: bool) -> None: + position = "code" if self._inCodeScope else "header" + sourceContainer = _getPlatformContainer(_platformModuleId(self.filePath)) + targetContainer = _getPlatformContainer(importedModule) if isInternal else None + self.imports.append( + ImportRecord( + importedModule=importedModule, + position=position, + isInternal=isInternal, + sourceContainer=sourceContainer, + targetContainer=targetContainer, + ) + ) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + previous = self._inCodeScope + self._inCodeScope = True + self.generic_visit(node) + self._inCodeScope = previous + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + previous = self._inCodeScope + self._inCodeScope = True + self.generic_visit(node) + self._inCodeScope = previous + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + previous = self._inCodeScope + self._inCodeScope = True + self.generic_visit(node) + self._inCodeScope = previous + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + resolved, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name) + self._addImport(resolved, isInternal) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + if node.level > 0: + resolved = _resolvePlatformRelativeImport(self.filePath, node) + if resolved: + self._addImport(resolved, True) + else: + suffix = node.module or "" + display = ("." * node.level) + suffix + self._addImport(f"(relative-unresolved) {display}", False) + return + + if not node.module: + return + + resolved, isInternal = _resolvePlatformImportTarget(self.filePath, node.module) + self._addImport(resolved, isInternal) + + +def _analyzePythonFile(filePath: Path) -> Optional[ModuleAnalysis]: + container = _getPlatformContainer(_platformModuleId(filePath)) + if container is None: + return None + + try: + source = filePath.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(filePath)) + except (SyntaxError, UnicodeDecodeError) as error: + print(f"WARN parse failed: {filePath}: {error}") + return None + + visitor = _PythonImportVisitor(filePath) + visitor.visit(tree) + + moduleId = _platformModuleId(filePath) + return ModuleAnalysis( + context="platform", + moduleId=moduleId, + filePath=filePath, + container=container, + containerPath=_platformContainerPath(container), + imports=visitor.imports, + ) + + +def _collectPlatformModules() -> List[ModuleAnalysis]: + modules: List[ModuleAnalysis] = [] + scanRoots = [PLATFORM_ROOT / "modules", PLATFORM_ROOT / "app.py"] + pyFiles: List[Path] = [] + if scanRoots[1].exists(): + pyFiles.append(scanRoots[1]) + for root, dirs, files in os.walk(scanRoots[0]): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES] + for fileName in files: + if fileName.endswith(".py"): + pyFiles.append(Path(root) / fileName) + + for filePath in pyFiles: + analysis = _analyzePythonFile(filePath) + if analysis: + modules.append(analysis) + return modules + + +# --------------------------------------------------------------------------- +# UI (TypeScript) +# --------------------------------------------------------------------------- + +TS_IMPORT_FROM_RE = re.compile( + r"""(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[\w*\s{},\n\r]+?\sfrom\s+)?['"]([^'"]+)['"]""", + re.MULTILINE, +) +TS_SIDE_EFFECT_IMPORT_RE = re.compile( + r"""(?:^|\n)\s*import\s+['"]([^'"]+)['"]\s*;""", + re.MULTILINE, +) +TS_DYNAMIC_IMPORT_RE = re.compile(r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""") + + +def _uiModuleId(filePath: Path) -> str: + rel = filePath.relative_to(UI_ROOT / "src") + if filePath.name == "index.ts" or filePath.name == "index.tsx": + parts = rel.parent.parts + else: + parts = rel.with_suffix("").parts + return "ui-nyla.src." + ".".join(parts) + + +def _uiContainerPath(container: str) -> str: + if container.startswith("pages."): + suffix = container.split(".", 1)[1] + if suffix in ("admin", "basedata", "billing", "settings", "workflowAutomation"): + return f"ui-nyla/src/pages/{suffix}" + return f"ui-nyla/src/pages/views/{suffix}" + if container.startswith("components."): + suffix = container.split(".", 1)[1] + return f"ui-nyla/src/components/{suffix}" + return f"ui-nyla/src/{container}" + + +def _getUiContainer(moduleId: str) -> Optional[str]: + if not moduleId.startswith("ui-nyla.src."): + return None + + parts = moduleId.replace("ui-nyla.src.", "").split(".") + if not parts: + return None + + top = parts[0] + if top == "test": + return None + + if top == "pages": + if len(parts) >= 3 and parts[1] == "views": + return f"pages.{parts[2]}" + if len(parts) >= 2: + return f"pages.{parts[1]}" + return "pages" + + if top == "components" and len(parts) >= 2: + return f"components.{parts[1]}" + + return top + + +def _resolveUiImport(currentFile: Path, spec: str) -> Tuple[str, bool]: + if spec.startswith("."): + resolvedPath = (currentFile.parent / spec).resolve() + candidates = [ + resolvedPath, + resolvedPath.with_suffix(".ts"), + resolvedPath.with_suffix(".tsx"), + resolvedPath / "index.ts", + resolvedPath / "index.tsx", + ] + for candidate in candidates: + if candidate.exists() and candidate.is_relative_to(UI_ROOT / "src"): + return _uiModuleId(candidate), True + relDisplay = spec + return relDisplay, False + + return spec, False + + +def _findTsImportPosition(source: str, matchStart: int) -> str: + depth = 0 + inFunction = False + functionDepth = 0 + i = 0 + while i < matchStart: + char = source[i] + if char == "{": + depth += 1 + elif char == "}": + depth = max(0, depth - 1) + if inFunction and depth < functionDepth: + inFunction = False + i += 1 + + lookback = source[max(0, matchStart - 400):matchStart] + if re.search(r"(?:function\s*\w*\s*\(|=>\s*\{|(?:async\s+)?function\s+\w+\s*\()", lookback): + tail = lookback[lookback.rfind("\n") + 1:] + if "=>" in tail or "function" in tail: + bracePos = source.find("{", max(0, matchStart - 120), matchStart) + if bracePos >= 0: + return "code" + + return "header" if depth == 0 and not inFunction else "code" + + +def _analyzeTypeScriptFile(filePath: Path) -> Optional[ModuleAnalysis]: + container = _getUiContainer(_uiModuleId(filePath)) + if container is None: + return None + + try: + source = filePath.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + print(f"WARN read failed: {filePath}: {error}") + return None + + imports: List[ImportRecord] = [] + seen: Set[Tuple[str, str, str]] = set() + + def _register(spec: str, position: str) -> None: + resolved, isInternal = _resolveUiImport(filePath, spec) + key = (resolved, position, spec) + if key in seen: + return + seen.add(key) + sourceContainer = container + targetContainer = _getUiContainer(resolved) if isInternal else None + imports.append( + ImportRecord( + importedModule=resolved, + position=position, + isInternal=isInternal, + sourceContainer=sourceContainer, + targetContainer=targetContainer, + ) + ) + + for match in TS_IMPORT_FROM_RE.finditer(source): + position = _findTsImportPosition(source, match.start()) + _register(match.group(1), position) + + for match in TS_SIDE_EFFECT_IMPORT_RE.finditer(source): + if match.group(1) in {m.group(1) for m in TS_IMPORT_FROM_RE.finditer(source)}: + continue + position = _findTsImportPosition(source, match.start()) + _register(match.group(1), position) + + for match in TS_DYNAMIC_IMPORT_RE.finditer(source): + position = _findTsImportPosition(source, match.start()) + _register(match.group(1), position) + + moduleId = _uiModuleId(filePath) + return ModuleAnalysis( + context="ui", + moduleId=moduleId, + filePath=filePath, + container=container, + containerPath=_uiContainerPath(container), + imports=imports, + ) + + +def _collectUiModules() -> List[ModuleAnalysis]: + srcRoot = UI_ROOT / "src" + modules: List[ModuleAnalysis] = [] + for filePath in srcRoot.rglob("*"): + if not filePath.is_file(): + continue + if filePath.suffix not in (".ts", ".tsx"): + continue + rel = filePath.relative_to(srcRoot).as_posix() + if rel.startswith("test/") or rel.endswith(".test.ts") or rel.endswith(".test.tsx"): + continue + analysis = _analyzeTypeScriptFile(filePath) + if analysis: + modules.append(analysis) + return modules + + +# --------------------------------------------------------------------------- +# Markdown output +# --------------------------------------------------------------------------- + +def _renderModuleMarkdown(module: ModuleAnalysis) -> str: + lines = [ + f"# Module Import Analysis: `{module.moduleId}`", + "", + f"- **Kontext:** {module.context}", + f"- **Container:** `{module.container}`", + f"- **Container-Pfad:** `{module.containerPath}`", + f"- **Datei:** `{module.filePath.relative_to(REPO_ROOT).as_posix()}`", + f"- **Import-Anzahl:** {len(module.imports)}", + "", + "## Imports", + "", + "| Modul | Position | Intern |", + "|-------|----------|--------|", + ] + + for item in sorted(module.imports, key=lambda x: (x.importedModule, x.position)): + internal = "ja" if item.isInternal else "nein" + lines.append(f"| `{item.importedModule}` | {item.position} | {internal} |") + + if not module.imports: + lines.append("| _keine_ | | |") + + lines.append("") + return "\n".join(lines) + + +@dataclass +class ContainerStats: + container: str + containerPath: str + importsFrom: Dict[str, int] = field(default_factory=lambda: defaultdict(int)) + exportedTo: Dict[str, int] = field(default_factory=lambda: defaultdict(int)) + mixedWith: Dict[str, Tuple[int, int]] = field(default_factory=dict) + + +def _buildContainerStats(modules: Iterable[ModuleAnalysis]) -> Dict[str, ContainerStats]: + statsByContainer: Dict[str, ContainerStats] = {} + + for module in modules: + if module.container not in statsByContainer: + statsByContainer[module.container] = ContainerStats( + container=module.container, + containerPath=module.containerPath, + ) + + for item in module.imports: + if not item.isInternal: + continue + if not item.sourceContainer or not item.targetContainer: + continue + if item.sourceContainer == item.targetContainer: + continue + + stats = statsByContainer[item.sourceContainer] + stats.importsFrom[item.targetContainer] += 1 + + targetStats = statsByContainer.get(item.targetContainer) + if targetStats is None: + targetStats = ContainerStats( + container=item.targetContainer, + containerPath=_platformContainerPath(item.targetContainer) + if module.context == "platform" + else _uiContainerPath(item.targetContainer), + ) + statsByContainer[item.targetContainer] = targetStats + targetStats.exportedTo[item.sourceContainer] += 1 + + for containerName, stats in statsByContainer.items(): + mixed: Dict[str, Tuple[int, int]] = {} + for other, outCount in stats.importsFrom.items(): + inCount = stats.exportedTo.get(other, 0) + if inCount > 0: + mixed[other] = (outCount, inCount) + stats.mixedWith = mixed + + return statsByContainer + + +def _renderContainerMarkdown(context: str, stats: ContainerStats) -> str: + importsTotal = sum(stats.importsFrom.values()) + exportsTotal = sum(stats.exportedTo.values()) + mixedTotal = sum(min(pair[0], pair[1]) for pair in stats.mixedWith.values()) + + lines = [ + f"# Container Import Analysis: `{stats.container}`", + "", + f"- **Kontext:** {context}", + f"- **Container-Pfad:** `{stats.containerPath}`", + "", + "## Imports aus anderen Containern", + "", + f"- **Anzahl:** {importsTotal}", + f"- **Container ({len(stats.importsFrom)}):** " + + (", ".join(f"`{name}` ({count})" for name, count in sorted(stats.importsFrom.items())) or "_keine_"), + "", + "## Exports zu anderen Containern", + "", + f"- **Anzahl:** {exportsTotal}", + f"- **Container ({len(stats.exportedTo)}):** " + + (", ".join(f"`{name}` ({count})" for name, count in sorted(stats.exportedTo.items())) or "_keine_"), + "", + "## Cross (mixed Import/Export)", + "", + f"- **Anzahl bidirektionaler Paare:** {len(stats.mixedWith)}", + f"- **Mindest-Wechselzahl (min je Richtung):** {mixedTotal}", + ] + + if stats.mixedWith: + lines.extend(["", "| Container | Importe hinaus | Importe herein |", "|-----------|----------------|----------------|"]) + for other, (outCount, inCount) in sorted(stats.mixedWith.items()): + lines.append(f"| `{other}` | {outCount} | {inCount} |") + else: + lines.append("- **Container:** _keine_") + + lines.append("") + return "\n".join(lines) + + +def _renderReadme(platformModules: int, uiModules: int, platformContainers: int, uiContainers: int) -> str: + return f"""# PORTA Import-Analyse + +Generiert am {date.today().isoformat()} durch `platform-core/scripts/script_analyze_porta_imports.py`. + +## Umfang + +| Kontext | Module | Container | +|---------|--------|-----------| +| platform | {platformModules} | {platformContainers} | +| ui | {uiModules} | {uiContainers} | + +## Struktur + +- `import-analysis-platform.md` — konsolidierte Platform-Übersicht (Tabelle) +- `import-analysis-platform-modules.md` — Modul-Graph: Gegenimporte, Loops, lazy Imports +- `import-analysis-ui.md` — konsolidierte UI-Übersicht (Tabelle) +- `platform/modules/` — ein Markdown pro Python-Modul (alle Imports inkl. lazy) +- `platform/containers/` — aggregierte Container-Statistik +- `platform/container-network.drawio` — Container-Vernetzung (schwarz=einweg, rot=mixed) +- `ui/modules/` — ein Markdown pro TS/TSX-Modul +- `ui/containers/` — aggregierte Container-Statistik +- `ui/container-network.drawio` — Container-Vernetzung +- `container-network.drawio` — kombiniert (2 Diagramm-Tabs: platform + ui) + +## Position + +- `header` — Import auf Modulebene (Top-Level) +- `code` — Import innerhalb von Funktion/Klasse oder dynamisch (`import()`) + +## Regenerieren + +```bash +python platform-core/scripts/script_analyze_porta_imports.py +``` +""" + + +# --------------------------------------------------------------------------- +# draw.io +# --------------------------------------------------------------------------- + +CONTAINER_COLORS = { + "app": "#dae8fc", + "aicore": "#d5e8d4", + "auth": "#ffe6cc", + "connectors": "#e1d5e7", + "datamodels": "#fff2cc", + "interfaces": "#f8cecc", + "routes": "#d0cee2", + "security": "#fad7ac", + "serviceCenter": "#b1ddf0", + "shared": "#f0fff0", + "workflows": "#f5f5f5", + "workflowAutomation": "#e6d0de", + "system": "#cce5ff", + "dbHelpers": "#fff0f5", + "nodeCatalog": "#f5fffa", + "pages": "#dae8fc", + "components": "#d5e8d4", + "hooks": "#ffe6cc", + "contexts": "#e1d5e7", + "api": "#fff2cc", + "layouts": "#f8cecc", + "providers": "#d0cee2", + "config": "#fad7ac", + "utils": "#b1ddf0", + "types": "#f0fff0", + "locales": "#f5f5f5", + "stores": "#e2efda", + "styles": "#fce5cd", +} + + +def _aggregateContainerEdges(statsByContainer: Dict[str, ContainerStats]) -> Dict[Tuple[str, str], Tuple[int, int, bool]]: + pairCounts: Dict[Tuple[str, str], Tuple[int, int]] = {} + + for stats in statsByContainer.values(): + for target, count in stats.importsFrom.items(): + key = (stats.container, target) + outCount, inCount = pairCounts.get(key, (0, 0)) + pairCounts[key] = (outCount + count, inCount) + + edges: Dict[Tuple[str, str], Tuple[int, int, bool]] = {} + processed: Set[Tuple[str, str]] = set() + + for (source, target), (forward, _) in list(pairCounts.items()): + pairKey = tuple(sorted((source, target))) + if pairKey in processed: + continue + processed.add(pairKey) + + a, b = pairKey + aToB = pairCounts.get((a, b), (0, 0))[0] + bToA = pairCounts.get((b, a), (0, 0))[0] + + if aToB == 0 and bToA == 0: + continue + + if aToB > 0 and bToA > 0: + edges[(a, b)] = (aToB, bToA, True) + elif aToB > 0: + edges[(a, b)] = (aToB, 0, False) + else: + edges[(b, a)] = (bToA, 0, False) + + return edges + + +def _generateDrawio(context: str, statsByContainer: Dict[str, ContainerStats]) -> str: + containers = sorted(statsByContainer.keys()) + edges = _aggregateContainerEdges(statsByContainer) + + centerX = 700 + centerY = 550 + radius = 430 + nodeWidth = 170 + nodeHeight = 62 + + containerPositions: Dict[str, Tuple[int, int]] = {} + for index, container in enumerate(containers): + angle = (2 * math.pi * index / max(len(containers), 1)) - math.pi / 2 + x = int(centerX + radius * math.cos(angle) - nodeWidth / 2) + y = int(centerY + radius * math.sin(angle) - nodeHeight / 2) + containerPositions[container] = (x, y) + + cells: List[str] = [] + for container in containers: + x, y = containerPositions[container] + base = container.split(".")[0] + color = CONTAINER_COLORS.get(base, "#ffffff") + label = f"{container}\\n({sum(statsByContainer[container].importsFrom.values())} out / " + label += f"{sum(statsByContainer[container].exportedTo.values())} in)" + cellId = f"container_{container.replace('.', '_')}" + cells.append( + f""" + + """ + ) + + edgeId = 1000 + for (source, target), (forward, backward, isMixed) in sorted(edges.items(), key=lambda item: -(item[1][0] + item[1][1])): + sourceId = f"container_{source.replace('.', '_')}" + targetId = f"container_{target.replace('.', '_')}" + if isMixed: + label = f"{forward} / {backward}" + strokeColor = "#CC0000" + else: + label = str(forward) + strokeColor = "#000000" + + strokeWidth = min(1 + (forward + backward) // 15, 6) + cells.append( + f""" + + """ + ) + edgeId += 1 + + innerXml = f""" + + + +{chr(10).join(cells)} + + """ + + return _wrapDrawioDiagram(context, innerXml) + + +def _wrapDrawioDiagram(context: str, innerXml: str) -> str: + return f""" + + +{innerXml} + + +""" + + +def _extractDrawioDiagramBody(drawioXml: str) -> str: + start = drawioXml.index("") + return drawioXml[start:end] + + +def _combineDrawioFiles(platformDrawio: str, uiDrawio: str) -> str: + return f""" + + +{_extractDrawioDiagramBody(platformDrawio)} + + +{_extractDrawioDiagramBody(uiDrawio)} + + +""" + + +SUMMARY_FILE_PLATFORM = "import-analysis-platform.md" +SUMMARY_FILE_UI = "import-analysis-ui.md" + + +def _renderConsolidatedSummary( + title: str, + context: str, + detailFolder: str, + statsByContainer: Dict[str, ContainerStats], + diagramPath: str, +) -> str: + lines = [ + f"# {title}", + "", + f"- **Kontext:** {context}", + f"- **Generiert:** {date.today().isoformat()}", + f"- **Detail-Dateien:** `{detailFolder}/`", + "", + "## Container", + "", + "| Container | Imports out | Exports in | Mixed | Detail |", + "|-----------|------------:|-----------:|------:|--------|", + ] + + for containerName in sorted(statsByContainer.keys()): + stats = statsByContainer[containerName] + importsOut = sum(stats.importsFrom.values()) + exportsIn = sum(stats.exportedTo.values()) + mixedCount = len(stats.mixedWith) + detailLink = f"[Detail]({detailFolder}/containers/{_sanitizeFileName(containerName)}.md)" + lines.append( + f"| `{containerName}` | {importsOut} | {exportsIn} | {mixedCount} | {detailLink} |" + ) + + lines.extend( + [ + "", + f"Diagramm: [{diagramPath}]({diagramPath})", + "", + ] + ) + return "\n".join(lines) + + +def _writeContextOutput(context: str, modules: List[ModuleAnalysis]) -> Tuple[int, str]: + contextRoot = OUTPUT_ROOT / context + modulesDir = contextRoot / "modules" + containersDir = contextRoot / "containers" + + for module in modules: + fileName = _sanitizeFileName(module.moduleId) + ".md" + _writeText(modulesDir / fileName, _renderModuleMarkdown(module)) + + statsByContainer = _buildContainerStats(modules) + for containerName, stats in sorted(statsByContainer.items()): + fileName = _sanitizeFileName(containerName) + ".md" + _writeText(containersDir / fileName, _renderContainerMarkdown(context, stats)) + + drawio = _generateDrawio(context, statsByContainer) + _writeText(contextRoot / "container-network.drawio", drawio) + + return len(statsByContainer), drawio + + +def main() -> None: + print("Analyzing platform-core (Python)...") + platformModules = _collectPlatformModules() + print(f" modules: {len(platformModules)}") + + print("Analyzing ui-nyla (TypeScript)...") + uiModules = _collectUiModules() + print(f" modules: {len(uiModules)}") + + platformContainerCount, platformDrawio = _writeContextOutput("platform", platformModules) + uiContainerCount, uiDrawio = _writeContextOutput("ui", uiModules) + + combinedDrawio = _combineDrawioFiles(platformDrawio, uiDrawio) + _writeText(OUTPUT_ROOT / "container-network.drawio", combinedDrawio) + + readme = _renderReadme( + platformModules=len(platformModules), + uiModules=len(uiModules), + platformContainers=platformContainerCount, + uiContainers=uiContainerCount, + ) + _writeText(OUTPUT_ROOT / "README.md", readme) + + platformStats = _buildContainerStats(platformModules) + platformSummary = _renderConsolidatedSummary( + title="Import-Analyse Platform Core", + context="platform", + detailFolder="platform", + statsByContainer=platformStats, + diagramPath="platform/container-network.drawio", + ) + _writeText(OUTPUT_ROOT / SUMMARY_FILE_PLATFORM, platformSummary) + + uiStats = _buildContainerStats(uiModules) + uiSummary = _renderConsolidatedSummary( + title="Import-Analyse UI Nyla", + context="ui", + detailFolder="ui", + statsByContainer=uiStats, + diagramPath="ui/container-network.drawio", + ) + _writeText(OUTPUT_ROOT / SUMMARY_FILE_UI, uiSummary) + + print(f"\nOutput written to: {OUTPUT_ROOT}") + print(f" platform containers: {platformContainerCount}") + print(f" ui containers: {uiContainerCount}") + + +if __name__ == "__main__": + main() diff --git a/scripts/script_remove_redundant_platform_imports.py b/scripts/script_remove_redundant_platform_imports.py new file mode 100644 index 00000000..4081c7e4 --- /dev/null +++ b/scripts/script_remove_redundant_platform_imports.py @@ -0,0 +1,165 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. +""" +Remove redundant lazy imports in platform-core when the same internal module +is already imported at module header level. + +Usage: + python platform-core/scripts/script_remove_redundant_platform_imports.py + python platform-core/scripts/script_remove_redundant_platform_imports.py --dry-run +""" + +from __future__ import annotations + +import argparse +import ast +import os +import sys +from pathlib import Path +from typing import Dict, List, Set, Tuple + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from script_analyze_porta_imports import ( # noqa: E402 + PLATFORM_ROOT, + SKIP_DIR_NAMES, + _getPlatformContainer, + _platformModuleId, + _resolvePlatformImportTarget, + _resolvePlatformRelativeImport, +) + + +class _RedundantImportFinder(ast.NodeVisitor): + def __init__(self, filePath: Path): + self.filePath = filePath + self.headerTargets: Set[str] = set() + self.linesToRemove: Set[int] = set() + self._scopeDepth = 0 + + def _resolveImportNode(self, node: ast.Import | ast.ImportFrom) -> List[Tuple[str, bool]]: + resolved: List[Tuple[str, bool]] = [] + if isinstance(node, ast.ImportFrom): + if node.level > 0: + target = _resolvePlatformRelativeImport(self.filePath, node) + if target: + resolved.append((target, True)) + return resolved + if not node.module: + return resolved + target, isInternal = _resolvePlatformImportTarget(self.filePath, node.module) + resolved.append((target, isInternal)) + return resolved + + for alias in node.names: + target, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name) + resolved.append((target, isInternal)) + return resolved + + def _handleImportNode(self, node: ast.Import | ast.ImportFrom) -> None: + for target, isInternal in self._resolveImportNode(node): + if not isInternal or not target.startswith("platform-core."): + continue + if self._scopeDepth == 0: + self.headerTargets.add(target) + elif target in self.headerTargets: + endLine = getattr(node, "end_lineno", None) or node.lineno + for lineNo in range(node.lineno, endLine + 1): + self.linesToRemove.add(lineNo) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._scopeDepth += 1 + self.generic_visit(node) + self._scopeDepth -= 1 + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._scopeDepth += 1 + self.generic_visit(node) + self._scopeDepth -= 1 + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._scopeDepth += 1 + self.generic_visit(node) + self._scopeDepth -= 1 + + def visit_Import(self, node: ast.Import) -> None: + self._handleImportNode(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + self._handleImportNode(node) + + +def _moduleIdToFilePath(moduleId: str) -> Path: + rel = moduleId.replace("platform-core.", "") + parts = rel.split(".") + candidate = PLATFORM_ROOT.joinpath(*parts).with_suffix(".py") + if candidate.exists(): + return candidate + initFile = PLATFORM_ROOT.joinpath(*parts, "__init__.py") + return initFile + + +def _collectPythonFiles() -> List[Path]: + pyFiles: List[Path] = [] + appFile = PLATFORM_ROOT / "app.py" + if appFile.exists(): + pyFiles.append(appFile) + for root, dirs, files in os.walk(PLATFORM_ROOT / "modules"): + dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES] + for fileName in files: + if fileName.endswith(".py"): + pyFiles.append(Path(root) / fileName) + return pyFiles + + +def _removeLines(filePath: Path, linesToRemove: Set[int], dryRun: bool) -> int: + if not linesToRemove: + return 0 + + lines = filePath.read_text(encoding="utf-8").splitlines(keepends=True) + newLines = [line for index, line in enumerate(lines, start=1) if index not in linesToRemove] + + if not dryRun: + filePath.write_text("".join(newLines), encoding="utf-8") + return len(linesToRemove) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + totalRemoved = 0 + filesChanged = 0 + details: List[Tuple[str, int]] = [] + + for filePath in _collectPythonFiles(): + moduleId = _platformModuleId(filePath) + if _getPlatformContainer(moduleId) is None: + continue + try: + tree = ast.parse(filePath.read_text(encoding="utf-8"), filename=str(filePath)) + except (SyntaxError, UnicodeDecodeError) as error: + print(f"WARN skip {filePath}: {error}") + continue + + finder = _RedundantImportFinder(filePath) + finder.visit(tree) + if not finder.linesToRemove: + continue + + removed = _removeLines(filePath, finder.linesToRemove, args.dry_run) + totalRemoved += removed + filesChanged += 1 + rel = filePath.relative_to(PLATFORM_ROOT.parent).as_posix() + details.append((rel, removed)) + action = "would remove" if args.dry_run else "removed" + print(f"{action} {removed} from {rel}") + + print(f"\nFiles touched: {filesChanged}") + print(f"Import lines {('would be ' if args.dry_run else '')}removed: {totalRemoved}") + + +if __name__ == "__main__": + main()