import cleanup
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 56s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped

This commit is contained in:
ValueOn AG 2026-06-11 22:54:39 +02:00
parent a1d9c68604
commit 63e30c1281
55 changed files with 1691 additions and 149 deletions

10
app.py
View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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)]

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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__

View file

@ -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)

View file

@ -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()

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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":

View file

@ -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 = []

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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"

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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})

View file

@ -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

View file

@ -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(

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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:

View file

@ -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))

View file

@ -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(

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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""" <mxCell id="{cellId}" value="{html.escape(label)}" """
f"""style="rounded=1;whiteSpace=wrap;html=1;fillColor={color};strokeColor=#666666;fontStyle=1;fontSize=11;" """
f"""vertex="1" parent="1">
<mxGeometry x="{x}" y="{y}" width="{nodeWidth}" height="{nodeHeight}" as="geometry" />
</mxCell>"""
)
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""" <mxCell id="edge_{edgeId}" value="{html.escape(label)}" """
f"""style="edgeStyle=none;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"""
f"""endArrow=block;endFill=1;strokeWidth={strokeWidth};strokeColor={strokeColor};"""
f"""fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" """
f"""edge="1" parent="1" source="{sourceId}" target="{targetId}">
<mxGeometry relative="1" as="geometry" />
</mxCell>"""
)
edgeId += 1
innerXml = f""" <mxGraphModel dx="1434" dy="780" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
{chr(10).join(cells)}
</root>
</mxGraphModel>"""
return _wrapDrawioDiagram(context, innerXml)
def _wrapDrawioDiagram(context: str, innerXml: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="{date.today().isoformat()}T00:00:00.000Z" agent="script_analyze_porta_imports.py" version="21.0.0" type="device">
<diagram id="{context}-container-network" name="{context} container imports">
{innerXml}
</diagram>
</mxfile>
"""
def _extractDrawioDiagramBody(drawioXml: str) -> str:
start = drawioXml.index("<mxGraphModel")
end = drawioXml.index("</diagram>")
return drawioXml[start:end]
def _combineDrawioFiles(platformDrawio: str, uiDrawio: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="{date.today().isoformat()}T00:00:00.000Z" agent="script_analyze_porta_imports.py" version="21.0.0" type="device">
<diagram id="platform-container-network" name="platform container imports">
{_extractDrawioDiagramBody(platformDrawio)}
</diagram>
<diagram id="ui-container-network" name="ui container imports">
{_extractDrawioDiagramBody(uiDrawio)}
</diagram>
</mxfile>
"""
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()

View file

@ -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()