refactor: architecture cleanup + fix scheduler Automation2Workflow error
Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot) Refactor: gdprDeletion via onUserDelete lifecycle hooks Refactor: i18nBootSync accounting labels via app.py parameter injection Refactor: serviceHub moved to serviceCenter/serviceHub.py Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling Cleanup: remove obsolete methodTrustee, serviceExceptions shim Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bc7c6fe27c
commit
cf0233f193
59 changed files with 7662 additions and 8339 deletions
59
app.py
59
app.py
|
|
@ -329,6 +329,14 @@ async def lifespan(app: FastAPI):
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
registerAllFeaturesInCatalog(catalogService)
|
registerAllFeaturesInCatalog(catalogService)
|
||||||
logger.info("Feature catalog registration completed")
|
logger.info("Feature catalog registration completed")
|
||||||
|
|
||||||
|
# Register service center RBAC objects (Composition Root — avoids system→serviceCenter import)
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter import registerServiceObjects
|
||||||
|
registerServiceObjects(catalogService)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Service center RBAC registration failed: {e}")
|
||||||
|
|
||||||
# Persist the in-memory feature registry into the Feature DB-table so
|
# Persist the in-memory feature registry into the Feature DB-table so
|
||||||
# the FeatureInstance.featureCode FK has real targets. Without this
|
# the FeatureInstance.featureCode FK has real targets. Without this
|
||||||
# every FeatureInstance row would be flagged as orphan by the
|
# every FeatureInstance row would be flagged as orphan by the
|
||||||
|
|
@ -343,7 +351,22 @@ async def lifespan(app: FastAPI):
|
||||||
# Sync gateway i18n registry to DB and load translation cache
|
# Sync gateway i18n registry to DB and load translation cache
|
||||||
try:
|
try:
|
||||||
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
||||||
await syncRegistryToDb()
|
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
|
||||||
|
serviceLabels = [svc.get("label") for svc in IMPORTABLE_SERVICES.values()]
|
||||||
|
|
||||||
|
accountingLabels = []
|
||||||
|
try:
|
||||||
|
from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
|
||||||
|
registry = getAccountingRegistry()
|
||||||
|
for connectorType, connector in (registry._connectors or {}).items():
|
||||||
|
for field in connector.getRequiredConfigFields():
|
||||||
|
label = getattr(field, "label", "") or ""
|
||||||
|
if label:
|
||||||
|
accountingLabels.append({"label": label, "connectorType": connectorType})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await syncRegistryToDb(serviceLabels=serviceLabels, accountingLabels=accountingLabels)
|
||||||
await loadCache()
|
await loadCache()
|
||||||
logger.info("i18n registry sync + cache load completed")
|
logger.info("i18n registry sync + cache load completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -409,9 +432,41 @@ async def lifespan(app: FastAPI):
|
||||||
try:
|
try:
|
||||||
main_loop = asyncio.get_running_loop()
|
main_loop = asyncio.get_running_loop()
|
||||||
eventManager.set_event_loop(main_loop)
|
eventManager.set_event_loop(main_loop)
|
||||||
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||||
setSchedulerMainLoop(main_loop)
|
setSchedulerMainLoop(main_loop)
|
||||||
|
|
||||||
|
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
|
||||||
|
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()
|
||||||
|
if not rootInterface:
|
||||||
|
return
|
||||||
|
eventUser = rootInterface.getUserByUsername("event")
|
||||||
|
if not eventUser:
|
||||||
|
return
|
||||||
|
ctx = ServiceCenterContext(
|
||||||
|
user=eventUser,
|
||||||
|
mandate_id=mandateId or "",
|
||||||
|
feature_instance_id="",
|
||||||
|
feature_code="graphicalEditor",
|
||||||
|
)
|
||||||
|
messagingService = getService("messaging", ctx)
|
||||||
|
subscriptionId = "GraphicalEditorRunFailed"
|
||||||
|
eventParams = MessagingEventParameters(triggerData={
|
||||||
|
"workflowId": workflowId,
|
||||||
|
"workflowLabel": workflowLabel or workflowId,
|
||||||
|
"runId": runId,
|
||||||
|
"error": error,
|
||||||
|
"mandateId": mandateId or "",
|
||||||
|
})
|
||||||
|
messagingService.executeSubscription(subscriptionId, eventParams)
|
||||||
|
|
||||||
|
setOnRunFailedCallback(_onRunFailed)
|
||||||
|
|
||||||
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
|
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
|
||||||
# when clients (browsers) close connections abruptly. This is a known
|
# when clients (browsers) close connections abruptly. This is a known
|
||||||
# asyncio issue on Windows: https://bugs.python.org/issue39010
|
# asyncio issue on Windows: https://bugs.python.org/issue39010
|
||||||
|
|
|
||||||
348
modules/datamodels/datamodelNavigation.py
Normal file
348
modules/datamodels/datamodelNavigation.py
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Navigation structure data (Layer L1 - datamodels).
|
||||||
|
Single source of truth for UI navigation sections used by RBAC and frontend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Navigation Structure (Single Source of Truth)
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Block Order (gemaess Navigation-API-Konzept):
|
||||||
|
# - System: 10
|
||||||
|
# - <dynamic/features>: 15 (wird in routeSystem.py eingefuegt)
|
||||||
|
# - Basisdaten: 30
|
||||||
|
# - Administration: 200
|
||||||
|
#
|
||||||
|
# NOTE: Workflows and Migrate sections removed - now handled as features
|
||||||
|
#
|
||||||
|
# Item Order: Default-Abstand 10 pro Item
|
||||||
|
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
|
||||||
|
# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben
|
||||||
|
|
||||||
|
NAVIGATION_SECTIONS = [
|
||||||
|
# --- Meine Sicht (with top-level item + subgroups) ---
|
||||||
|
{
|
||||||
|
"id": "system",
|
||||||
|
"title": t("Meine Sicht"),
|
||||||
|
"order": 10,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "home",
|
||||||
|
"objectKey": "ui.system.home",
|
||||||
|
"label": t("Start"),
|
||||||
|
"icon": "FaHome",
|
||||||
|
"path": "/",
|
||||||
|
"order": 10,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"subgroups": [
|
||||||
|
{
|
||||||
|
"id": "system-overviews",
|
||||||
|
"title": t("Übersichten"),
|
||||||
|
"order": 15,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "integrations",
|
||||||
|
"objectKey": "ui.system.integrations",
|
||||||
|
"label": t("Integrationen"),
|
||||||
|
"icon": "FaProjectDiagram",
|
||||||
|
"path": "/integrations",
|
||||||
|
"order": 10,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "compliance-audit",
|
||||||
|
"objectKey": "ui.system.complianceAudit",
|
||||||
|
"label": t("Compliance & Audit"),
|
||||||
|
"icon": "FaShieldAlt",
|
||||||
|
"path": "/compliance-audit",
|
||||||
|
"order": 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-basedata",
|
||||||
|
"title": t("Basisdaten"),
|
||||||
|
"order": 20,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "connections",
|
||||||
|
"objectKey": "ui.system.connections",
|
||||||
|
"label": t("Verbindungen"),
|
||||||
|
"icon": "FaLink",
|
||||||
|
"path": "/basedata/connections",
|
||||||
|
"order": 10,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "files",
|
||||||
|
"objectKey": "ui.system.files",
|
||||||
|
"label": t("Dateien"),
|
||||||
|
"icon": "FaRegFileAlt",
|
||||||
|
"path": "/basedata/files",
|
||||||
|
"order": 20,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prompts",
|
||||||
|
"objectKey": "ui.system.prompts",
|
||||||
|
"label": t("Prompts"),
|
||||||
|
"icon": "FaLightbulb",
|
||||||
|
"path": "/basedata/prompts",
|
||||||
|
"order": 30,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "system-usage",
|
||||||
|
"title": t("Nutzung"),
|
||||||
|
"order": 30,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "billing-admin",
|
||||||
|
"objectKey": "ui.system.billingAdmin",
|
||||||
|
"label": t("Abrechnung"),
|
||||||
|
"icon": "FaMoneyBillAlt",
|
||||||
|
"path": "/billing/admin",
|
||||||
|
"order": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "statistics",
|
||||||
|
"objectKey": "ui.system.statistics",
|
||||||
|
"label": t("Statistiken"),
|
||||||
|
"icon": "FaChartBar",
|
||||||
|
"path": "/billing/transactions",
|
||||||
|
"order": 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "automations",
|
||||||
|
"objectKey": "ui.system.automations",
|
||||||
|
"label": t("Automations"),
|
||||||
|
"icon": "FaRobot",
|
||||||
|
"path": "/automations",
|
||||||
|
"order": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rag-inventory",
|
||||||
|
"objectKey": "ui.system.ragInventory",
|
||||||
|
"label": t("RAG-Inventar"),
|
||||||
|
"icon": "FaDatabase",
|
||||||
|
"path": "/rag-inventory",
|
||||||
|
"order": 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "store",
|
||||||
|
"objectKey": "ui.system.store",
|
||||||
|
"label": t("Store"),
|
||||||
|
"icon": "FaStore",
|
||||||
|
"path": "/store",
|
||||||
|
"order": 40,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "settings",
|
||||||
|
"objectKey": "ui.system.settings",
|
||||||
|
"label": t("Einstellungen"),
|
||||||
|
"icon": "FaCog",
|
||||||
|
"path": "/settings",
|
||||||
|
"order": 50,
|
||||||
|
"public": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# --- Administration (with subgroups) ---
|
||||||
|
{
|
||||||
|
"id": "admin",
|
||||||
|
"title": t("Administration"),
|
||||||
|
"order": 200,
|
||||||
|
"subgroups": [
|
||||||
|
{
|
||||||
|
"id": "admin-wizards",
|
||||||
|
"title": t("Wizards"),
|
||||||
|
"order": 10,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "admin-mandate-wizard",
|
||||||
|
"objectKey": "ui.admin.mandateWizard",
|
||||||
|
"label": t("Mandanten-Wizard"),
|
||||||
|
"icon": "FaMagic",
|
||||||
|
"path": "/admin/mandate-wizard",
|
||||||
|
"order": 10,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-invitation-wizard",
|
||||||
|
"objectKey": "ui.admin.invitationWizard",
|
||||||
|
"label": t("Einladungs-Wizard"),
|
||||||
|
"icon": "FaEnvelopeOpenText",
|
||||||
|
"path": "/admin/invitation-wizard",
|
||||||
|
"order": 20,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-users-group",
|
||||||
|
"title": t("Benutzer"),
|
||||||
|
"order": 20,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "admin-users",
|
||||||
|
"objectKey": "ui.admin.users",
|
||||||
|
"label": t("Benutzer"),
|
||||||
|
"icon": "FaUsers",
|
||||||
|
"path": "/admin/users",
|
||||||
|
"order": 10,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-invitations",
|
||||||
|
"objectKey": "ui.admin.invitations",
|
||||||
|
"label": t("Benutzer-Einladungen"),
|
||||||
|
"icon": "FaEnvelopeOpenText",
|
||||||
|
"path": "/admin/invitations",
|
||||||
|
"order": 20,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-user-access-overview",
|
||||||
|
"objectKey": "ui.admin.userAccessOverview",
|
||||||
|
"label": t("Benutzer-Zugriffsübersicht"),
|
||||||
|
"icon": "FaClipboardList",
|
||||||
|
"path": "/admin/user-access-overview",
|
||||||
|
"order": 30,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-subscriptions",
|
||||||
|
"objectKey": "ui.admin.subscriptions",
|
||||||
|
"label": t("Abonnements"),
|
||||||
|
"icon": "FaFileContract",
|
||||||
|
"path": "/admin/subscriptions",
|
||||||
|
"order": 40,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-system-group",
|
||||||
|
"title": t("System"),
|
||||||
|
"order": 30,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "admin-roles",
|
||||||
|
"objectKey": "ui.admin.roles",
|
||||||
|
"label": t("Rollen"),
|
||||||
|
"icon": "FaUserTag",
|
||||||
|
"path": "/admin/mandate-roles",
|
||||||
|
"order": 10,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-mandate-role-permissions",
|
||||||
|
"objectKey": "ui.admin.mandateRolePermissions",
|
||||||
|
"label": t("Rollen-Berechtigungen"),
|
||||||
|
"icon": "FaKey",
|
||||||
|
"path": "/admin/mandate-role-permissions",
|
||||||
|
"order": 20,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-mandates",
|
||||||
|
"objectKey": "ui.admin.mandates",
|
||||||
|
"label": t("Mandanten"),
|
||||||
|
"icon": "FaBuilding",
|
||||||
|
"path": "/admin/mandates",
|
||||||
|
"order": 30,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-user-mandates",
|
||||||
|
"objectKey": "ui.admin.userMandates",
|
||||||
|
"label": t("Mandanten-Mitglieder"),
|
||||||
|
"icon": "FaUserFriends",
|
||||||
|
"path": "/admin/user-mandates",
|
||||||
|
"order": 40,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-access",
|
||||||
|
"objectKey": "ui.admin.access",
|
||||||
|
"label": t("Zugriffsverwaltung"),
|
||||||
|
"icon": "FaBuilding",
|
||||||
|
"path": "/admin/access",
|
||||||
|
"order": 50,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-feature-instances",
|
||||||
|
"objectKey": "ui.admin.featureInstances",
|
||||||
|
"label": t("Feature-Instanzen"),
|
||||||
|
"icon": "FaCubes",
|
||||||
|
"path": "/admin/feature-instances",
|
||||||
|
"order": 60,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-feature-roles",
|
||||||
|
"objectKey": "ui.admin.featureRoles",
|
||||||
|
"label": t("Features Rollen-Vorlagen"),
|
||||||
|
"icon": "FaShieldAlt",
|
||||||
|
"path": "/admin/feature-roles",
|
||||||
|
"order": 70,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-logs",
|
||||||
|
"objectKey": "ui.admin.logs",
|
||||||
|
"label": t("Logs"),
|
||||||
|
"icon": "FaFileAlt",
|
||||||
|
"path": "/admin/logs",
|
||||||
|
"order": 90,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-languages",
|
||||||
|
"objectKey": "ui.admin.languages",
|
||||||
|
"label": t("UI-Sprachen"),
|
||||||
|
"icon": "FaGlobe",
|
||||||
|
"path": "/admin/languages",
|
||||||
|
"order": 95,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-database-health",
|
||||||
|
"objectKey": "ui.admin.databaseHealth",
|
||||||
|
"label": t("Datenbank-Gesundheit"),
|
||||||
|
"icon": "FaDatabase",
|
||||||
|
"path": "/admin/database-health",
|
||||||
|
"order": 98,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-demo-config",
|
||||||
|
"objectKey": "ui.admin.demoConfig",
|
||||||
|
"label": t("Demo Config"),
|
||||||
|
"icon": "FaCubes",
|
||||||
|
"path": "/admin/demo-config",
|
||||||
|
"order": 100,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -46,6 +46,8 @@ from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
AutoRun,
|
AutoRun,
|
||||||
AutoStepLog,
|
AutoStepLog,
|
||||||
AutoTask,
|
AutoTask,
|
||||||
|
Automation2Workflow,
|
||||||
|
Automation2WorkflowRun,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
|
||||||
|
|
@ -279,3 +279,27 @@ def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to cascade-delete neutralization data for mandate {mandateId}: {e}")
|
logger.warning(f"Failed to cascade-delete neutralization data for mandate {mandateId}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def onUserDelete(userId: str, currentUser) -> dict:
|
||||||
|
"""Delete/anonymize user data from the neutralization database (GDPR)."""
|
||||||
|
from modules.system.gdprDeletion import deleteUserDataFromDatabase
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
dbName = "poweron_neutralization"
|
||||||
|
try:
|
||||||
|
db = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase=dbName,
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
stats = deleteUserDataFromDatabase(db, userId, dbName)
|
||||||
|
db.close()
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"onUserDelete neutralization failed: {e}")
|
||||||
|
return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from urllib.parse import urlparse, unquote
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot
|
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot
|
||||||
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
|
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
|
||||||
from modules.serviceHub import getInterface as getServices
|
from modules.serviceCenter.serviceHub import getInterface as getServices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
949
modules/features/realEstate/handlerRealEstate.py
Normal file
949
modules/features/realEstate/handlerRealEstate.py
Normal file
|
|
@ -0,0 +1,949 @@
|
||||||
|
"""
|
||||||
|
Handler functions for Real Estate feature routes.
|
||||||
|
Contains extracted business logic from route handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelPagination import (
|
||||||
|
PaginationParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationMetadata,
|
||||||
|
)
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
from .datamodelFeatureRealEstate import (
|
||||||
|
Projekt,
|
||||||
|
Parzelle,
|
||||||
|
Dokument,
|
||||||
|
DokumentTyp,
|
||||||
|
Gemeinde,
|
||||||
|
Kanton,
|
||||||
|
Land,
|
||||||
|
Kontext,
|
||||||
|
StatusProzess,
|
||||||
|
)
|
||||||
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
|
from .mainRealEstate import (
|
||||||
|
create_project_with_parcel_data,
|
||||||
|
extract_bzo_information,
|
||||||
|
)
|
||||||
|
from .parcelSelectionService import is_parcel_adjacent_to_selection
|
||||||
|
|
||||||
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
|
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
|
routeApiMsg = apiRouteContext("routeFeatureRealEstate")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# GEMEINDE / BZO HANDLERS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def processGemeindenSync(interface, instanceId: str, mandateId: str, onlyCurrent: bool = True) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch all Gemeinden from Swiss Topo and save to DB for an instance.
|
||||||
|
Creates Kantone as needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
oerebConnector = OerebWfsConnector()
|
||||||
|
connector = SwissTopoMapServerConnector(oereb_connector=oerebConnector)
|
||||||
|
gemeindenData = await connector.get_all_gemeinden(only_current=onlyCurrent)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}")
|
||||||
|
|
||||||
|
gemeindenCreated = 0
|
||||||
|
gemeindenSkipped = 0
|
||||||
|
kantoneCreated = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
kantonCache: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def _findGemeindeByBfsNummer(bfsNummer: str) -> Optional[Any]:
|
||||||
|
try:
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
for g in gemeinden:
|
||||||
|
for k in (g.kontextInformationen or []):
|
||||||
|
try:
|
||||||
|
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
|
||||||
|
if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfsNummer):
|
||||||
|
return g
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error finding Gemeinde by BFS {bfsNummer}: {ex}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _getOrCreateKanton(kantonAbk: str) -> Optional[str]:
|
||||||
|
nonlocal kantoneCreated, errors
|
||||||
|
if not kantonAbk:
|
||||||
|
return None
|
||||||
|
if kantonAbk in kantonCache:
|
||||||
|
return kantonCache[kantonAbk]
|
||||||
|
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kantonAbk})
|
||||||
|
if kantone:
|
||||||
|
kantonCache[kantonAbk] = kantone[0].id
|
||||||
|
return kantone[0].id
|
||||||
|
kantonNames = {
|
||||||
|
"AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
|
||||||
|
"BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
|
||||||
|
"FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
|
||||||
|
"JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
|
||||||
|
"OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
|
||||||
|
"SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
|
||||||
|
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
kantonLabel = kantonNames.get(kantonAbk, kantonAbk)
|
||||||
|
kanton = Kanton(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=kantonLabel,
|
||||||
|
abk=kantonAbk,
|
||||||
|
)
|
||||||
|
created = interface.createKanton(kanton)
|
||||||
|
if created and created.id:
|
||||||
|
kantonCache[kantonAbk] = created.id
|
||||||
|
kantoneCreated += 1
|
||||||
|
return created.id
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(f"Error creating Kanton {kantonAbk}: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
savedGemeinden: List[Dict[str, Any]] = []
|
||||||
|
for gd in gemeindenData:
|
||||||
|
try:
|
||||||
|
gemeindeName = gd.get("name")
|
||||||
|
bfsNummer = gd.get("bfs_nummer")
|
||||||
|
kantonAbk = gd.get("kanton")
|
||||||
|
if not gemeindeName or bfsNummer is None:
|
||||||
|
gemeindenSkipped += 1
|
||||||
|
continue
|
||||||
|
existing = _findGemeindeByBfsNummer(str(bfsNummer))
|
||||||
|
if existing:
|
||||||
|
gemeindenSkipped += 1
|
||||||
|
savedGemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing)
|
||||||
|
continue
|
||||||
|
kantonId = _getOrCreateKanton(kantonAbk) if kantonAbk else None
|
||||||
|
gemeinde = Gemeinde(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=gemeindeName,
|
||||||
|
id_kanton=kantonId,
|
||||||
|
kontextInformationen=[
|
||||||
|
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfsNummer}, ensure_ascii=False))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
created = interface.createGemeinde(gemeinde)
|
||||||
|
if created and created.id:
|
||||||
|
gemeindenCreated += 1
|
||||||
|
savedGemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created)
|
||||||
|
else:
|
||||||
|
errors.append(f"Failed to create Gemeinde {gemeindeName}")
|
||||||
|
gemeindenSkipped += 1
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}")
|
||||||
|
gemeindenSkipped += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gemeinden": savedGemeinden,
|
||||||
|
"count": len(savedGemeinden),
|
||||||
|
"stats": {
|
||||||
|
"gemeinden_created": gemeindenCreated,
|
||||||
|
"gemeinden_skipped": gemeindenSkipped,
|
||||||
|
"kantone_created": kantoneCreated,
|
||||||
|
"error_count": len(errors),
|
||||||
|
"errors": errors[:10],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def processBzoDocumentsFetch(interface, componentInterface, mandateId: str, instanceId: str) -> Dict[str, Any]:
|
||||||
|
"""Search for and download BZO documents for all Gemeinden of an instance."""
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde
|
||||||
|
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []}
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for gemeinde in gemeinden:
|
||||||
|
gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None}
|
||||||
|
try:
|
||||||
|
stats["gemeinden_processed"] += 1
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(
|
||||||
|
interface, componentInterface, gemeinde, mandateId, instanceId
|
||||||
|
)
|
||||||
|
if fetched:
|
||||||
|
gr["status"] = "created"
|
||||||
|
stats["documents_created"] += 1
|
||||||
|
refreshed = interface.getGemeinde(gemeinde.id)
|
||||||
|
if refreshed and refreshed.dokumente:
|
||||||
|
for doc in refreshed.dokumente:
|
||||||
|
docId = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None)
|
||||||
|
if docId:
|
||||||
|
gr["dokument_ids"].append(docId)
|
||||||
|
else:
|
||||||
|
gr["status"] = "skipped"
|
||||||
|
stats["documents_skipped"] += 1
|
||||||
|
except Exception as ex:
|
||||||
|
gr["status"] = "error"
|
||||||
|
gr["error"] = str(ex)
|
||||||
|
stats["errors"].append(f"{gemeinde.label}: {str(ex)}")
|
||||||
|
results.append(gr)
|
||||||
|
|
||||||
|
return {"success": True, "stats": stats, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
async def processParcelDocuments(interface, componentInterface, gemeindeName: str, bauzone: str, mandateId: str, instanceId: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Ensure BZO document exists for Gemeinde, return documents for parcel info display.
|
||||||
|
Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB.
|
||||||
|
"""
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import (
|
||||||
|
ensure_single_gemeinde,
|
||||||
|
fetch_bzo_for_gemeinde,
|
||||||
|
)
|
||||||
|
|
||||||
|
gemeindeObj = None
|
||||||
|
byLabel = interface.getGemeinden(recordFilter={"label": gemeindeName, "mandateId": mandateId})
|
||||||
|
gemeindeObj = byLabel[0] if byLabel else None
|
||||||
|
|
||||||
|
if not gemeindeObj:
|
||||||
|
allG = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
gNorm = gemeindeName.strip().lower()
|
||||||
|
for g in allG:
|
||||||
|
gl = (g.label or "").strip().lower()
|
||||||
|
if gl == gNorm or gNorm in gl or gl in gNorm:
|
||||||
|
gemeindeObj = g
|
||||||
|
logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeindeName}' -> '{g.label}'")
|
||||||
|
break
|
||||||
|
|
||||||
|
if gemeindeObj:
|
||||||
|
logger.debug(f"parcel-documents: Gemeinde '{gemeindeName}' resolved: {gemeindeObj.id}")
|
||||||
|
|
||||||
|
if not gemeindeObj:
|
||||||
|
logger.info(f"parcel-documents: No Gemeinde for label '{gemeindeName}', ensuring via Swiss Topo...")
|
||||||
|
gemeindeObj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeindeName)
|
||||||
|
|
||||||
|
if not gemeindeObj:
|
||||||
|
logger.warning(f"parcel-documents: Gemeinde '{gemeindeName}' nicht gefunden (mandateId={mandateId[:8]}...)")
|
||||||
|
return {"documents": [], "error": f"Gemeinde '{gemeindeName}' nicht gefunden"}
|
||||||
|
|
||||||
|
bzoDocs = []
|
||||||
|
if gemeindeObj.dokumente:
|
||||||
|
for doc in gemeindeObj.dokumente:
|
||||||
|
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
|
||||||
|
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]:
|
||||||
|
docId = doc.id if hasattr(doc, "id") else doc.get("id")
|
||||||
|
if docId:
|
||||||
|
full = interface.getDokument(docId)
|
||||||
|
if full and full.dokumentReferenz:
|
||||||
|
bzoDocs.append(full)
|
||||||
|
|
||||||
|
if not bzoDocs:
|
||||||
|
logger.info(f"parcel-documents: No BZO for {gemeindeName}, fetching...")
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeindeObj, mandateId, instanceId)
|
||||||
|
if fetched:
|
||||||
|
gemeindeObj = interface.getGemeinde(gemeindeObj.id)
|
||||||
|
if gemeindeObj and gemeindeObj.dokumente:
|
||||||
|
for doc in gemeindeObj.dokumente:
|
||||||
|
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
|
||||||
|
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
|
||||||
|
docId = doc.id if hasattr(doc, "id") else doc.get("id")
|
||||||
|
if docId:
|
||||||
|
full = interface.getDokument(docId)
|
||||||
|
if full and full.dokumentReferenz:
|
||||||
|
bzoDocs.append(full)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for d in bzoDocs:
|
||||||
|
result.append({
|
||||||
|
"id": d.id,
|
||||||
|
"label": d.label,
|
||||||
|
"fileId": d.dokumentReferenz,
|
||||||
|
"fileName": (d.label or "BZO") + ".pdf",
|
||||||
|
"mimeType": d.mimeType or "application/pdf",
|
||||||
|
})
|
||||||
|
return {"documents": result, "gemeinde": gemeindeName, "bauzone": bauzone}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LEGACY TABLE HANDLERS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def processTableData(user, mandateId: Optional[str], table: str, pagination: Optional[str]) -> PaginatedResponse:
|
||||||
|
"""Fetch and paginate table data for a real estate entity table."""
|
||||||
|
tableMapping = {
|
||||||
|
"Projekt": (Projekt, "getProjekte"),
|
||||||
|
"Parzelle": (Parzelle, "getParzellen"),
|
||||||
|
"Dokument": (Dokument, "getDokumente"),
|
||||||
|
"Gemeinde": (Gemeinde, "getGemeinden"),
|
||||||
|
"Kanton": (Kanton, "getKantone"),
|
||||||
|
"Land": (Land, "getLaender"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if table not in tableMapping:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
||||||
|
modelClass, methodName = tableMapping[table]
|
||||||
|
getterMethod = getattr(realEstateInterface, methodName)
|
||||||
|
items = getterMethod(recordFilter=None)
|
||||||
|
|
||||||
|
paginationParams = None
|
||||||
|
if pagination:
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(pagination)
|
||||||
|
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if paginationParams:
|
||||||
|
if paginationParams.sort:
|
||||||
|
for sortField in reversed(paginationParams.sort):
|
||||||
|
fieldName = sortField.field
|
||||||
|
direction = sortField.direction.lower()
|
||||||
|
|
||||||
|
def _sortKey(item, _fieldName=fieldName):
|
||||||
|
value = getattr(item, _fieldName, None)
|
||||||
|
if value is None:
|
||||||
|
return (1, None)
|
||||||
|
return (0, value)
|
||||||
|
|
||||||
|
items.sort(key=_sortKey, reverse=(direction == "desc"))
|
||||||
|
|
||||||
|
totalItems = len(items)
|
||||||
|
totalPages = (totalItems + paginationParams.pageSize - 1) // paginationParams.pageSize
|
||||||
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
|
endIdx = startIdx + paginationParams.pageSize
|
||||||
|
paginatedItems = items[startIdx:endIdx]
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=paginatedItems,
|
||||||
|
pagination=PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def processCreateTableRecord(user, mandateId: Optional[str], table: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Create a record in a real estate table, with special handling for Projekt+parcel."""
|
||||||
|
if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
|
||||||
|
logger.info(f"Creating Projekt with parcel data for user {user.id}")
|
||||||
|
|
||||||
|
label = data.get("label")
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=routeApiMsg("label is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
statusProzess = data.get("statusProzess", "Eingang")
|
||||||
|
|
||||||
|
parzellenData = []
|
||||||
|
if "parzellen" in data:
|
||||||
|
parzellenData = data.get("parzellen", [])
|
||||||
|
if not isinstance(parzellenData, list):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=routeApiMsg("parzellen must be an array")
|
||||||
|
)
|
||||||
|
elif "parzelle" in data:
|
||||||
|
parzelleData = data.get("parzelle")
|
||||||
|
if parzelleData:
|
||||||
|
parzellenData = [parzelleData]
|
||||||
|
|
||||||
|
if not parzellenData:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=routeApiMsg("parzelle or parzellen data is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await create_project_with_parcel_data(
|
||||||
|
currentUser=user,
|
||||||
|
mandateId=mandateId,
|
||||||
|
projekt_label=label,
|
||||||
|
parzellen_data=parzellenData,
|
||||||
|
status_prozess=statusProzess,
|
||||||
|
)
|
||||||
|
return result.get("projekt", {})
|
||||||
|
|
||||||
|
tableMapping = {
|
||||||
|
"Projekt": (Projekt, "createProjekt"),
|
||||||
|
"Parzelle": (Parzelle, "createParzelle"),
|
||||||
|
"Dokument": (Dokument, "createDokument"),
|
||||||
|
"Gemeinde": (Gemeinde, "createGemeinde"),
|
||||||
|
"Kanton": (Kanton, "createKanton"),
|
||||||
|
"Land": (Land, "createLand"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if table not in tableMapping:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
||||||
|
modelClass, methodName = tableMapping[table]
|
||||||
|
createMethod = getattr(realEstateInterface, methodName)
|
||||||
|
|
||||||
|
if "mandateId" not in data:
|
||||||
|
data["mandateId"] = mandateId
|
||||||
|
|
||||||
|
try:
|
||||||
|
modelInstance = modelClass(**data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid data for {table}: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
createdRecord = createMethod(modelInstance)
|
||||||
|
if hasattr(createdRecord, 'model_dump'):
|
||||||
|
return createdRecord.model_dump()
|
||||||
|
return createdRecord
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PARCEL SEARCH HANDLER
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def processParcelSearch(user, mandateId: Optional[str], location: str, includeBauzone: bool, includeAdjacent: bool) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Search for parcel information by address or coordinates.
|
||||||
|
Resolves address, calculates geometry, optionally fetches adjacent parcels and bauzone.
|
||||||
|
"""
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
parcelData = await connector.search_parcel(location)
|
||||||
|
|
||||||
|
if not parcelData:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No parcel found for location: {location}"
|
||||||
|
)
|
||||||
|
|
||||||
|
extractedAttributes = connector.extract_parcel_attributes(parcelData)
|
||||||
|
attributes = parcelData.get("attributes", {})
|
||||||
|
geometry = parcelData.get("geometry", {})
|
||||||
|
|
||||||
|
areaM2 = None
|
||||||
|
centroid = None
|
||||||
|
if extractedAttributes.get("perimeter"):
|
||||||
|
perimeter = extractedAttributes["perimeter"]
|
||||||
|
points = perimeter.get("punkte", [])
|
||||||
|
if len(points) >= 3:
|
||||||
|
area = 0
|
||||||
|
for i in range(len(points)):
|
||||||
|
j = (i + 1) % len(points)
|
||||||
|
area += points[i]["x"] * points[j]["y"]
|
||||||
|
area -= points[j]["x"] * points[i]["y"]
|
||||||
|
areaM2 = abs(area / 2)
|
||||||
|
sumX = sum(p["x"] for p in points)
|
||||||
|
sumY = sum(p["y"] for p in points)
|
||||||
|
centroid = {"x": sumX / len(points), "y": sumY / len(points)}
|
||||||
|
|
||||||
|
canton = attributes.get("ak", "")
|
||||||
|
municipalityName = None
|
||||||
|
fullAddress = None
|
||||||
|
plz = None
|
||||||
|
|
||||||
|
geocodedAddress = parcelData.get('geocoded_address')
|
||||||
|
if geocodedAddress:
|
||||||
|
fullAddress = geocodedAddress.get('full_address')
|
||||||
|
plz = geocodedAddress.get('plz')
|
||||||
|
municipalityName = geocodedAddress.get('municipality')
|
||||||
|
logger.debug(f"Using geocoded address: {fullAddress}")
|
||||||
|
|
||||||
|
queryCoords = parcelData.get('query_coordinates')
|
||||||
|
addressQueryCoords = queryCoords if queryCoords else centroid
|
||||||
|
|
||||||
|
if not fullAddress and addressQueryCoords:
|
||||||
|
queryX = addressQueryCoords['x']
|
||||||
|
queryY = addressQueryCoords['y']
|
||||||
|
logger.debug(f"Querying address layer at query coordinates: ({queryX}, {queryY})")
|
||||||
|
|
||||||
|
isCoordinateSearch = ',' in location and not any(c.isalpha() for c in location.split(',')[0])
|
||||||
|
buildingTolerance = 1 if isCoordinateSearch else 10
|
||||||
|
buildingResult = await connector._query_building_layer(queryX, queryY, tolerance=buildingTolerance, buffer=25)
|
||||||
|
|
||||||
|
if buildingResult:
|
||||||
|
addrAttrs = buildingResult.get("attributes", {})
|
||||||
|
logger.debug(f"Address layer attributes: {addrAttrs}")
|
||||||
|
addressInfo = connector._extract_address_from_building_attrs(addrAttrs)
|
||||||
|
fullAddress = addressInfo.get('full_address')
|
||||||
|
plz = addressInfo.get('plz')
|
||||||
|
municipalityName = addressInfo.get('municipality')
|
||||||
|
if fullAddress:
|
||||||
|
logger.debug(f"Constructed address: {fullAddress}")
|
||||||
|
|
||||||
|
if not fullAddress:
|
||||||
|
if location and any(c.isalpha() for c in location) and "CH" not in location:
|
||||||
|
fullAddress = location
|
||||||
|
logger.debug(f"Using location as address: {fullAddress}")
|
||||||
|
|
||||||
|
if not municipalityName and fullAddress:
|
||||||
|
plzMunicipalityMatch = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", fullAddress)
|
||||||
|
if plzMunicipalityMatch:
|
||||||
|
extractedMunicipality = plzMunicipalityMatch.group(2).strip()
|
||||||
|
extractedMunicipality = re.sub(r"[,;\.]+$", "", extractedMunicipality).strip()
|
||||||
|
if extractedMunicipality:
|
||||||
|
municipalityName = extractedMunicipality
|
||||||
|
if not plz:
|
||||||
|
plz = plzMunicipalityMatch.group(1)
|
||||||
|
logger.debug(f"Extracted municipality from address: {municipalityName}")
|
||||||
|
|
||||||
|
bfsnr = attributes.get("bfsnr")
|
||||||
|
if not municipalityName and bfsnr and canton and mandateId:
|
||||||
|
try:
|
||||||
|
interface = getRealEstateInterface(user, mandateId=mandateId, featureInstanceId=None)
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
for g in gemeinden:
|
||||||
|
for k in (g.kontextInformationen or []):
|
||||||
|
try:
|
||||||
|
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
|
||||||
|
if isinstance(data, dict):
|
||||||
|
bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code")
|
||||||
|
if str(bfs) == str(bfsnr):
|
||||||
|
municipalityName = g.label
|
||||||
|
logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipalityName}")
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
if municipalityName:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying Gemeinde by BFS: {e}")
|
||||||
|
|
||||||
|
if not municipalityName and centroid and canton:
|
||||||
|
try:
|
||||||
|
geocodeUrl = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
||||||
|
params = {
|
||||||
|
"geometry": f"{centroid['x']},{centroid['y']}",
|
||||||
|
"geometryType": "esriGeometryPoint",
|
||||||
|
"layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill",
|
||||||
|
"tolerance": "0",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
"sr": "2056",
|
||||||
|
"f": "json",
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(geocodeUrl, params=params) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.json()
|
||||||
|
results = data.get("results", [])
|
||||||
|
if results:
|
||||||
|
attrs = results[0].get("attributes", {})
|
||||||
|
geoName = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label")
|
||||||
|
if geoName:
|
||||||
|
municipalityName = connector._clean_municipality_name(str(geoName))
|
||||||
|
logger.debug(f"Found municipality via Swiss Topo geocoding: {municipalityName}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying Swiss Topo geocoding: {e}")
|
||||||
|
|
||||||
|
if not municipalityName and bfsnr:
|
||||||
|
commonMunicipalities = {
|
||||||
|
261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur",
|
||||||
|
351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne",
|
||||||
|
1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz",
|
||||||
|
}
|
||||||
|
if bfsnr in commonMunicipalities:
|
||||||
|
municipalityName = commonMunicipalities[bfsnr]
|
||||||
|
logger.debug(f"Looked up municipality from common list: {municipalityName}")
|
||||||
|
elif canton and bfsnr:
|
||||||
|
municipalityName = f"{canton}-{bfsnr}"
|
||||||
|
logger.debug(f"Using fallback municipality: {municipalityName}")
|
||||||
|
|
||||||
|
if fullAddress and fullAddress.startswith("CH") and len(fullAddress) == 14 and fullAddress[2:].isdigit():
|
||||||
|
fullAddress = None
|
||||||
|
logger.debug("Removed EGRID from address field")
|
||||||
|
|
||||||
|
bauzone = None
|
||||||
|
hasGeometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
|
||||||
|
if includeBauzone and canton and hasGeometry and centroid:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
|
||||||
|
oerebConnector = OerebWfsConnector()
|
||||||
|
zoneResults = await oerebConnector.query_zone_layer(
|
||||||
|
egrid=attributes.get("egris_egrid", "") or "",
|
||||||
|
x=centroid["x"],
|
||||||
|
y=centroid["y"],
|
||||||
|
canton=canton,
|
||||||
|
geometry=geometry,
|
||||||
|
)
|
||||||
|
if zoneResults and len(zoneResults) > 0:
|
||||||
|
zoneAttrs = zoneResults[0].get("attributes", {})
|
||||||
|
typGdeAbkuerzung = zoneAttrs.get("typ_gde_abkuerzung")
|
||||||
|
if typGdeAbkuerzung:
|
||||||
|
bauzone = typGdeAbkuerzung
|
||||||
|
logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error querying zone information: {e}", exc_info=True)
|
||||||
|
|
||||||
|
parcelInfo = {
|
||||||
|
"id": attributes.get("label") or attributes.get("number"),
|
||||||
|
"egrid": attributes.get("egris_egrid"),
|
||||||
|
"number": attributes.get("number"),
|
||||||
|
"name": attributes.get("name"),
|
||||||
|
"identnd": attributes.get("identnd"),
|
||||||
|
"canton": attributes.get("ak"),
|
||||||
|
"municipality_code": attributes.get("bfsnr"),
|
||||||
|
"municipality_name": municipalityName,
|
||||||
|
"address": fullAddress,
|
||||||
|
"plz": plz,
|
||||||
|
"perimeter": extractedAttributes.get("perimeter"),
|
||||||
|
"area_m2": areaM2,
|
||||||
|
"centroid": centroid,
|
||||||
|
"geoportal_url": attributes.get("geoportal_url"),
|
||||||
|
"realestate_type": attributes.get("realestate_type"),
|
||||||
|
"bauzone": bauzone,
|
||||||
|
}
|
||||||
|
|
||||||
|
bbox = parcelData.get("bbox", [])
|
||||||
|
mapView = {
|
||||||
|
"center": centroid,
|
||||||
|
"zoom_bounds": {
|
||||||
|
"min_x": bbox[0] if len(bbox) >= 4 else None,
|
||||||
|
"min_y": bbox[1] if len(bbox) >= 4 else None,
|
||||||
|
"max_x": bbox[2] if len(bbox) >= 4 else None,
|
||||||
|
"max_y": bbox[3] if len(bbox) >= 4 else None
|
||||||
|
},
|
||||||
|
"geometry_geojson": {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[[p["x"], p["y"]] for p in extractedAttributes["perimeter"]["punkte"]]
|
||||||
|
] if extractedAttributes.get("perimeter") else []
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": parcelInfo["id"],
|
||||||
|
"egrid": parcelInfo["egrid"],
|
||||||
|
"number": parcelInfo["number"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData = {
|
||||||
|
"parcel": parcelInfo,
|
||||||
|
"map_view": mapView
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeAdjacent and parcelData and parcelData.get("geometry"):
|
||||||
|
try:
|
||||||
|
selectedParcelId = parcelInfo["id"]
|
||||||
|
adjacentParcelsRaw = await connector.find_neighboring_parcels(
|
||||||
|
parcel_data=parcelData,
|
||||||
|
selected_parcel_id=selectedParcelId,
|
||||||
|
sample_distance=20.0,
|
||||||
|
max_sample_points=30,
|
||||||
|
max_neighbors=15,
|
||||||
|
max_concurrent=50,
|
||||||
|
)
|
||||||
|
adjacentParcels = [_convertParcelGeometry(adjParcel) for adjParcel in adjacentParcelsRaw]
|
||||||
|
responseData["adjacent_parcels"] = adjacentParcels
|
||||||
|
logger.info(f"Found {len(adjacentParcels)} neighboring parcels for parcel {selectedParcelId}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True)
|
||||||
|
responseData["adjacent_parcels"] = []
|
||||||
|
|
||||||
|
return responseData
|
||||||
|
|
||||||
|
|
||||||
|
def _convertParcelGeometry(adjParcel: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Convert an adjacent parcel to include GeoJSON geometry."""
|
||||||
|
adjParcelWithGeo = {
|
||||||
|
"id": adjParcel["id"],
|
||||||
|
"egrid": adjParcel.get("egrid"),
|
||||||
|
"number": adjParcel.get("number"),
|
||||||
|
"perimeter": adjParcel.get("perimeter")
|
||||||
|
}
|
||||||
|
|
||||||
|
adjGeometry = adjParcel.get("geometry")
|
||||||
|
adjPerimeter = adjParcel.get("perimeter")
|
||||||
|
|
||||||
|
if adjGeometry:
|
||||||
|
if "rings" in adjGeometry and adjGeometry["rings"]:
|
||||||
|
ring = adjGeometry["rings"][0]
|
||||||
|
coordinates = [[[p[0], p[1]] for p in ring]]
|
||||||
|
adjParcelWithGeo["geometry_geojson"] = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": coordinates},
|
||||||
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
||||||
|
}
|
||||||
|
elif adjGeometry.get("type") == "Polygon":
|
||||||
|
adjParcelWithGeo["geometry_geojson"] = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": adjGeometry,
|
||||||
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
||||||
|
}
|
||||||
|
|
||||||
|
if "geometry_geojson" not in adjParcelWithGeo and adjPerimeter and adjPerimeter.get("punkte"):
|
||||||
|
punkte = adjPerimeter["punkte"]
|
||||||
|
coordinates = [[[p["x"], p["y"]] for p in punkte]]
|
||||||
|
adjParcelWithGeo["geometry_geojson"] = {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": coordinates},
|
||||||
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjParcelWithGeo
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADD ADJACENT PARCEL HANDLER
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def processAddAdjacentParcel(location: Dict[str, Any], selectedParcels: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add an adjacent parcel to the selection. Validates adjacency.
|
||||||
|
Returns parcel response with geometry.
|
||||||
|
"""
|
||||||
|
locStr = f"{location['x']},{location['y']}"
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
parcelData = await connector.search_parcel(locStr)
|
||||||
|
|
||||||
|
if not parcelData:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=routeApiMsg("No parcel found at this location")
|
||||||
|
)
|
||||||
|
|
||||||
|
extracted = connector.extract_parcel_attributes(parcelData)
|
||||||
|
attributes = parcelData.get("attributes", {})
|
||||||
|
geometry = parcelData.get("geometry", {})
|
||||||
|
|
||||||
|
areaM2 = None
|
||||||
|
centroid = None
|
||||||
|
if extracted.get("perimeter"):
|
||||||
|
perimeter = extracted["perimeter"]
|
||||||
|
points = perimeter.get("punkte", [])
|
||||||
|
if len(points) >= 3:
|
||||||
|
area = 0
|
||||||
|
for i in range(len(points)):
|
||||||
|
j = (i + 1) % len(points)
|
||||||
|
area += points[i]["x"] * points[j]["y"]
|
||||||
|
area -= points[j]["x"] * points[i]["y"]
|
||||||
|
areaM2 = abs(area / 2)
|
||||||
|
sumX = sum(p["x"] for p in points)
|
||||||
|
sumY = sum(p["y"] for p in points)
|
||||||
|
centroid = {"x": sumX / len(points), "y": sumY / len(points)}
|
||||||
|
|
||||||
|
parcelInfo = {
|
||||||
|
"id": attributes.get("label") or attributes.get("number"),
|
||||||
|
"egrid": attributes.get("egris_egrid"),
|
||||||
|
"number": attributes.get("number"),
|
||||||
|
"name": attributes.get("name"),
|
||||||
|
"identnd": attributes.get("identnd"),
|
||||||
|
"canton": attributes.get("ak"),
|
||||||
|
"municipality_code": attributes.get("bfsnr"),
|
||||||
|
"municipality_name": None,
|
||||||
|
"address": None,
|
||||||
|
"plz": None,
|
||||||
|
"perimeter": extracted.get("perimeter"),
|
||||||
|
"area_m2": areaM2,
|
||||||
|
"centroid": centroid,
|
||||||
|
"geoportal_url": attributes.get("geoportal_url"),
|
||||||
|
"realestate_type": attributes.get("realestate_type"),
|
||||||
|
"bauzone": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
mapView = {
|
||||||
|
"center": centroid,
|
||||||
|
"zoom_bounds": parcelData.get("bbox", []) and {
|
||||||
|
"min_x": parcelData["bbox"][0],
|
||||||
|
"min_y": parcelData["bbox"][1],
|
||||||
|
"max_x": parcelData["bbox"][2],
|
||||||
|
"max_y": parcelData["bbox"][3],
|
||||||
|
} or None,
|
||||||
|
"geometry_geojson": _buildGeometryGeojson(extracted, parcelInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
newParcelResponse = {"parcel": parcelInfo, "map_view": mapView}
|
||||||
|
|
||||||
|
if not is_parcel_adjacent_to_selection(newParcelResponse, selectedParcels):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden")
|
||||||
|
)
|
||||||
|
|
||||||
|
bbox = parcelData.get("bbox", [])
|
||||||
|
mapView["zoom_bounds"] = {
|
||||||
|
"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3]
|
||||||
|
} if len(bbox) >= 4 else None
|
||||||
|
|
||||||
|
geocodedAddress = parcelData.get("geocoded_address")
|
||||||
|
if geocodedAddress:
|
||||||
|
parcelInfo["municipality_name"] = geocodedAddress.get("municipality")
|
||||||
|
parcelInfo["address"] = geocodedAddress.get("full_address")
|
||||||
|
parcelInfo["plz"] = geocodedAddress.get("plz")
|
||||||
|
|
||||||
|
if centroid and attributes.get("ak"):
|
||||||
|
try:
|
||||||
|
oereb = OerebWfsConnector()
|
||||||
|
zoneResults = await oereb.query_zone_layer(
|
||||||
|
egrid=attributes.get("egris_egrid", "") or "",
|
||||||
|
x=centroid["x"], y=centroid["y"],
|
||||||
|
canton=attributes.get("ak"),
|
||||||
|
geometry=geometry,
|
||||||
|
)
|
||||||
|
if zoneResults and len(zoneResults) > 0:
|
||||||
|
parcelInfo["bauzone"] = zoneResults[0].get("attributes", {}).get("typ_gde_abkuerzung")
|
||||||
|
except Exception as oe:
|
||||||
|
logger.debug(f"ÖREB zone query failed: {oe}")
|
||||||
|
|
||||||
|
return newParcelResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _buildGeometryGeojson(extracted: Dict[str, Any], parcelInfo: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Build geometry_geojson from extracted perimeter."""
|
||||||
|
coords = []
|
||||||
|
if extracted.get("perimeter", {}).get("punkte"):
|
||||||
|
coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]]
|
||||||
|
return {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": coords},
|
||||||
|
"properties": {"id": parcelInfo["id"], "egrid": parcelInfo["egrid"], "number": parcelInfo["number"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ADD PARCEL TO PROJECT HANDLER
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def processAddParcelToProject(user, mandateId: Optional[str], projektId: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add a parcel to an existing project.
|
||||||
|
Supports linking existing, creating from location, or creating from custom data.
|
||||||
|
"""
|
||||||
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
||||||
|
|
||||||
|
recordFilter = {"id": projektId}
|
||||||
|
if mandateId:
|
||||||
|
recordFilter["mandateId"] = mandateId
|
||||||
|
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter)
|
||||||
|
if not projekte:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Projekt {projektId} not found"
|
||||||
|
)
|
||||||
|
projekt = projekte[0]
|
||||||
|
|
||||||
|
parcelId = body.get("parcelId")
|
||||||
|
location = body.get("location")
|
||||||
|
parcelDataDict = body.get("parcelData")
|
||||||
|
parzelle = None
|
||||||
|
|
||||||
|
if parcelId:
|
||||||
|
logger.info(f"Linking existing parcel {parcelId}")
|
||||||
|
parcelFilter = {"id": parcelId}
|
||||||
|
if mandateId:
|
||||||
|
parcelFilter["mandateId"] = mandateId
|
||||||
|
parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter)
|
||||||
|
if not parcels:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Parzelle {parcelId} not found"
|
||||||
|
)
|
||||||
|
parzelle = parcels[0]
|
||||||
|
|
||||||
|
elif location:
|
||||||
|
logger.info(f"Creating parcel from location: {location}")
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
parcelData = await connector.search_parcel(location)
|
||||||
|
if not parcelData:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No parcel found at location: {location}"
|
||||||
|
)
|
||||||
|
extractedAttributes = connector.extract_parcel_attributes(parcelData)
|
||||||
|
attributes = parcelData.get("attributes", {})
|
||||||
|
|
||||||
|
parzelleCreateData = {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"label": extractedAttributes.get("label") or attributes.get("number") or "Unknown",
|
||||||
|
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
|
||||||
|
"eigentuemerschaft": None,
|
||||||
|
"strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None,
|
||||||
|
"plz": None,
|
||||||
|
"perimeter": extractedAttributes.get("perimeter"),
|
||||||
|
"baulinie": None,
|
||||||
|
"kontextGemeinde": None,
|
||||||
|
"bauzone": None,
|
||||||
|
"az": None,
|
||||||
|
"bz": None,
|
||||||
|
"vollgeschossZahl": None,
|
||||||
|
"anrechenbarDachgeschoss": None,
|
||||||
|
"anrechenbarUntergeschoss": None,
|
||||||
|
"gebaeudehoeheMax": None,
|
||||||
|
"regelnGrenzabstand": [],
|
||||||
|
"regelnMehrlaengenzuschlag": [],
|
||||||
|
"regelnMehrhoehenzuschlag": [],
|
||||||
|
"parzelleBebaut": None,
|
||||||
|
"parzelleErschlossen": None,
|
||||||
|
"parzelleHanglage": None,
|
||||||
|
"laermschutzzone": None,
|
||||||
|
"hochwasserschutzzone": None,
|
||||||
|
"grundwasserschutzzone": None,
|
||||||
|
"parzellenNachbarschaft": [],
|
||||||
|
"dokumente": [],
|
||||||
|
"kontextInformationen": [
|
||||||
|
Kontext(
|
||||||
|
thema="Swiss Topo Data",
|
||||||
|
inhalt=json.dumps({
|
||||||
|
"egrid": attributes.get("egris_egrid"),
|
||||||
|
"identnd": attributes.get("identnd"),
|
||||||
|
"canton": attributes.get("ak"),
|
||||||
|
"municipality_code": attributes.get("bfsnr"),
|
||||||
|
"geoportal_url": attributes.get("geoportal_url")
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
parzelleInstance = Parzelle(**parzelleCreateData)
|
||||||
|
parzelle = realEstateInterface.createParzelle(parzelleInstance)
|
||||||
|
|
||||||
|
elif parcelDataDict:
|
||||||
|
logger.info(f"Creating parcel from custom data")
|
||||||
|
parcelDataDict["mandateId"] = mandateId
|
||||||
|
parzelleInstance = Parzelle(**parcelDataDict)
|
||||||
|
parzelle = realEstateInterface.createParzelle(parzelleInstance)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required")
|
||||||
|
|
||||||
|
if parzelle not in projekt.parzellen:
|
||||||
|
projekt.parzellen.append(parzelle)
|
||||||
|
|
||||||
|
if not projekt.perimeter and parzelle.perimeter:
|
||||||
|
projekt.perimeter = parzelle.perimeter
|
||||||
|
|
||||||
|
updatedProjekt = realEstateInterface.updateProjekt(projekt)
|
||||||
|
logger.info(f"Added Parzelle {parzelle.id} to Projekt {projektId}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"projekt": updatedProjekt.model_dump(),
|
||||||
|
"parzelle": parzelle.model_dump()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1087
modules/features/realEstate/serviceAiIntent.py
Normal file
1087
modules/features/realEstate/serviceAiIntent.py
Normal file
File diff suppressed because it is too large
Load diff
725
modules/features/realEstate/serviceBzo.py
Normal file
725
modules/features/realEstate/serviceBzo.py
Normal file
|
|
@ -0,0 +1,725 @@
|
||||||
|
"""
|
||||||
|
Real Estate feature — BZO (Bau- und Zonenordnung) information extraction.
|
||||||
|
|
||||||
|
Handles extraction of BZO information from PDF documents, filtering rules/zones/articles
|
||||||
|
by Bauzone, and generating AI summaries for building zone regulations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelUam import User
|
||||||
|
from .datamodelFeatureRealEstate import DokumentTyp
|
||||||
|
from modules.serviceCenter.serviceHub import getInterface as getServices
|
||||||
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
|
from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever
|
||||||
|
from modules.features.realEstate.bzoExtraction import run_extraction, run_bzo_params_extraction
|
||||||
|
from modules.features.realEstate.parcelSelectionService import compute_selection_summary
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import (
|
||||||
|
ensure_single_gemeinde,
|
||||||
|
fetch_bzo_for_gemeinde,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_bzo_information(
|
||||||
|
currentUser: User,
|
||||||
|
gemeinde: str,
|
||||||
|
bauzone: str,
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
|
total_area_m2: Optional[float] = None,
|
||||||
|
parcels: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
|
||||||
|
|
||||||
|
Retrieves BZO documents for the specified Gemeinde, extracts content using
|
||||||
|
the BZO extraction pipeline, filters by Bauzone, and uses AI to find relevant information.
|
||||||
|
When total_area_m2 or parcels are provided, runs Machbarkeitsstudie for structured output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current authenticated user
|
||||||
|
gemeinde: Gemeinde name (e.g., "Zürich") or ID
|
||||||
|
bauzone: Bauzone code (e.g., "W3", "W2/30")
|
||||||
|
mandateId: Optional mandate ID for instance-scoped data (defaults to currentUser.mandateId)
|
||||||
|
featureInstanceId: Optional feature instance ID for instance-scoped data
|
||||||
|
total_area_m2: Optional total parcel area (m²) for Machbarkeitsstudie
|
||||||
|
parcels: Optional list of parcel dicts; total area computed via compute_selection_summary if not total_area_m2
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing:
|
||||||
|
- bauzone, gemeinde, extracted_content, ai_summary, relevant_rules, documents_processed
|
||||||
|
- machbarkeitsstudie: Structured Machbarkeitsstudie output when total_area_m2/parcels provided
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_mandateId = mandateId or (str(currentUser.mandateId) if currentUser.mandateId else None)
|
||||||
|
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {_mandateId})")
|
||||||
|
|
||||||
|
realEstateInterface = getRealEstateInterface(
|
||||||
|
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
componentInterface = getComponentInterface(
|
||||||
|
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {_mandateId}")
|
||||||
|
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde)
|
||||||
|
|
||||||
|
if not gemeinde_obj:
|
||||||
|
logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}")
|
||||||
|
record_filter = {"label": gemeinde}
|
||||||
|
if _mandateId:
|
||||||
|
record_filter["mandateId"] = _mandateId
|
||||||
|
gemeinden_by_label = realEstateInterface.getGemeinden(
|
||||||
|
recordFilter=record_filter
|
||||||
|
)
|
||||||
|
if gemeinden_by_label and len(gemeinden_by_label) > 0:
|
||||||
|
gemeinde_obj = gemeinden_by_label[0]
|
||||||
|
logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}")
|
||||||
|
|
||||||
|
if not gemeinde_obj and _mandateId and featureInstanceId:
|
||||||
|
logger.info(f"Gemeinde '{gemeinde}' not in DB - fetching from Swiss Topo (this Gemeinde only)")
|
||||||
|
gemeinde_obj = await ensure_single_gemeinde(
|
||||||
|
realEstateInterface, _mandateId, featureInstanceId, gemeinde_name=gemeinde
|
||||||
|
)
|
||||||
|
|
||||||
|
if not gemeinde_obj:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Gemeinde '{gemeinde}' not found or not accessible"
|
||||||
|
)
|
||||||
|
|
||||||
|
gemeinde_id = gemeinde_obj.id
|
||||||
|
|
||||||
|
bzo_documents = []
|
||||||
|
if gemeinde_obj.dokumente:
|
||||||
|
for doc in gemeinde_obj.dokumente:
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
doc_id = doc.get("id")
|
||||||
|
doc_typ = doc.get("dokumentTyp")
|
||||||
|
else:
|
||||||
|
doc_id = doc.id if hasattr(doc, "id") else None
|
||||||
|
doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None
|
||||||
|
|
||||||
|
if doc_typ:
|
||||||
|
if isinstance(doc_typ, DokumentTyp):
|
||||||
|
is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]
|
||||||
|
elif isinstance(doc_typ, str):
|
||||||
|
is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
else:
|
||||||
|
doc_typ_str = str(doc_typ)
|
||||||
|
is_bzo = doc_typ_str in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
|
||||||
|
if is_bzo:
|
||||||
|
if doc_id:
|
||||||
|
full_doc = realEstateInterface.getDokument(doc_id)
|
||||||
|
if full_doc:
|
||||||
|
bzo_documents.append(full_doc)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database")
|
||||||
|
|
||||||
|
if not bzo_documents and _mandateId and featureInstanceId:
|
||||||
|
logger.info(f"No BZO documents for Gemeinde '{gemeinde_obj.label}' - fetching from web")
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(
|
||||||
|
realEstateInterface, componentInterface, gemeinde_obj, _mandateId, featureInstanceId
|
||||||
|
)
|
||||||
|
if fetched:
|
||||||
|
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde_obj.id)
|
||||||
|
bzo_documents = []
|
||||||
|
if gemeinde_obj and gemeinde_obj.dokumente:
|
||||||
|
for doc in gemeinde_obj.dokumente:
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
doc_id = doc.get("id")
|
||||||
|
doc_typ = doc.get("dokumentTyp")
|
||||||
|
else:
|
||||||
|
doc_id = doc.id if hasattr(doc, "id") else None
|
||||||
|
doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None
|
||||||
|
if doc_typ:
|
||||||
|
if isinstance(doc_typ, DokumentTyp):
|
||||||
|
is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]
|
||||||
|
elif isinstance(doc_typ, str):
|
||||||
|
is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
else:
|
||||||
|
is_bzo = str(doc_typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
if is_bzo and doc_id:
|
||||||
|
full_doc = realEstateInterface.getDokument(doc_id)
|
||||||
|
if full_doc:
|
||||||
|
bzo_documents.append(full_doc)
|
||||||
|
|
||||||
|
if not bzo_documents:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"No BZO documents found for Gemeinde '{gemeinde_obj.label}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(bzo_documents)} BZO document(s) for Gemeinde '{gemeinde_obj.label}'")
|
||||||
|
|
||||||
|
document_retriever = BZODocumentRetriever(realEstateInterface, componentInterface)
|
||||||
|
|
||||||
|
all_extracted_content = {
|
||||||
|
"articles": [],
|
||||||
|
"zones": [],
|
||||||
|
"rules": [],
|
||||||
|
"zone_parameter_tables": [],
|
||||||
|
"errors": [],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
documents_processed = []
|
||||||
|
|
||||||
|
for dokument in bzo_documents:
|
||||||
|
try:
|
||||||
|
logger.info(f"Processing document {dokument.id}: {dokument.label}")
|
||||||
|
|
||||||
|
pdf_bytes = document_retriever.retrieve_pdf_content(dokument)
|
||||||
|
if not pdf_bytes:
|
||||||
|
logger.warning(f"Could not retrieve PDF content for dokument {dokument.id}")
|
||||||
|
all_extracted_content["warnings"].append(
|
||||||
|
f"Could not retrieve PDF content for document '{dokument.label}'"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
extraction_result = run_extraction(
|
||||||
|
pdf_bytes=pdf_bytes,
|
||||||
|
pdf_id=dokument.dokumentReferenz or f"dok_{dokument.id}",
|
||||||
|
dokument_id=dokument.id
|
||||||
|
)
|
||||||
|
|
||||||
|
all_extracted_content["articles"].extend(extraction_result.get("articles", []))
|
||||||
|
all_extracted_content["zones"].extend(extraction_result.get("zones", []))
|
||||||
|
all_extracted_content["rules"].extend(extraction_result.get("rules", []))
|
||||||
|
all_extracted_content["zone_parameter_tables"].extend(extraction_result.get("zone_parameter_tables", []))
|
||||||
|
all_extracted_content["errors"].extend(extraction_result.get("errors", []))
|
||||||
|
all_extracted_content["warnings"].extend(extraction_result.get("warnings", []))
|
||||||
|
|
||||||
|
documents_processed.append({
|
||||||
|
"id": dokument.id,
|
||||||
|
"label": dokument.label,
|
||||||
|
"dokumentTyp": dokument.dokumentTyp.value if dokument.dokumentTyp else None
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing document {dokument.id}: {str(e)}", exc_info=True)
|
||||||
|
all_extracted_content["errors"].append(
|
||||||
|
f"Error processing document '{dokument.label}': {str(e)}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
relevant_rules = filter_rules_by_bauzone(
|
||||||
|
all_extracted_content["rules"],
|
||||||
|
bauzone
|
||||||
|
)
|
||||||
|
logger.info(f"Extracting for Bauzone {bauzone}: {len(relevant_rules)} zone-specific rules, "
|
||||||
|
f"{len([t for t in all_extracted_content.get('zone_parameter_tables', []) if bauzone.upper() in str(t.get('zones', [])).upper()])} tables with zone data")
|
||||||
|
|
||||||
|
relevant_zones = filter_zones_by_bauzone(
|
||||||
|
all_extracted_content["zones"],
|
||||||
|
bauzone
|
||||||
|
)
|
||||||
|
|
||||||
|
relevant_articles = filter_articles_by_bauzone(
|
||||||
|
all_extracted_content.get("articles", []),
|
||||||
|
bauzone
|
||||||
|
)
|
||||||
|
|
||||||
|
_total_area_m2 = total_area_m2
|
||||||
|
if _total_area_m2 is None and parcels:
|
||||||
|
selection_summary = compute_selection_summary(parcels)
|
||||||
|
_total_area_m2 = selection_summary.get("total_area_m2") or 0.0
|
||||||
|
|
||||||
|
bzo_params_result = None
|
||||||
|
try:
|
||||||
|
services = getServices(
|
||||||
|
currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
ai_service = services.ai
|
||||||
|
bzo_params_result = await run_bzo_params_extraction(
|
||||||
|
extracted_content=all_extracted_content,
|
||||||
|
bauzone=bauzone,
|
||||||
|
ai_service=ai_service,
|
||||||
|
gemeinde=gemeinde_obj.label,
|
||||||
|
relevant_rules=relevant_rules,
|
||||||
|
relevant_articles=relevant_articles,
|
||||||
|
total_area_m2=_total_area_m2,
|
||||||
|
)
|
||||||
|
except Exception as me:
|
||||||
|
logger.warning(f"BZO parameter extraction failed: {me}", exc_info=True)
|
||||||
|
all_extracted_content["warnings"] = all_extracted_content.get("warnings", []) + [
|
||||||
|
f"BZO-Parameter konnten nicht extrahiert werden: {str(me)}"
|
||||||
|
]
|
||||||
|
|
||||||
|
ai_summary = await generate_bauzone_ai_summary(
|
||||||
|
currentUser=currentUser,
|
||||||
|
bauzone=bauzone,
|
||||||
|
gemeinde=gemeinde_obj.label,
|
||||||
|
extracted_content=all_extracted_content,
|
||||||
|
relevant_rules=relevant_rules,
|
||||||
|
relevant_zones=relevant_zones,
|
||||||
|
mandateId=_mandateId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
unified_summary = ai_summary
|
||||||
|
|
||||||
|
summary_lower = unified_summary.lower()
|
||||||
|
|
||||||
|
zones_mentioned = any(zone.get("zone_code", "").upper() in summary_lower for zone in relevant_zones)
|
||||||
|
if not zones_mentioned and relevant_zones:
|
||||||
|
unified_summary += "\n\n=== ZONENDEFINITIONEN ===\n"
|
||||||
|
for zone in relevant_zones:
|
||||||
|
zone_code = zone.get("zone_code", "")
|
||||||
|
zone_name = zone.get("zone_name", "")
|
||||||
|
zone_category = zone.get("zone_category", "")
|
||||||
|
geschosszahl = zone.get("geschosszahl")
|
||||||
|
gewerbeerleichterung = zone.get("gewerbeerleichterung", False)
|
||||||
|
page_num = zone.get("page", 0)
|
||||||
|
source_article = zone.get("source_article", "")
|
||||||
|
|
||||||
|
zone_info = f"{zone_code}: {zone_name}"
|
||||||
|
if zone_category:
|
||||||
|
zone_info += f"\nKategorie: {zone_category}"
|
||||||
|
if geschosszahl:
|
||||||
|
zone_info += f"\nGeschosszahl: {geschosszahl}"
|
||||||
|
if gewerbeerleichterung:
|
||||||
|
zone_info += "\nGewerbeerleichterung: Ja"
|
||||||
|
if source_article:
|
||||||
|
zone_info += f"\nQuelle: {source_article} (Seite {page_num})"
|
||||||
|
unified_summary += zone_info + "\n\n"
|
||||||
|
|
||||||
|
articles_mentioned = any(article.get("article_label", "") in summary_lower for article in relevant_articles)
|
||||||
|
if not articles_mentioned and relevant_articles:
|
||||||
|
unified_summary += "\n\n=== RELEVANTE ARTIKEL ===\n"
|
||||||
|
for article in relevant_articles:
|
||||||
|
article_label = article.get("article_label", "")
|
||||||
|
article_title = article.get("article_title", "")
|
||||||
|
article_text = article.get("text", "")
|
||||||
|
page_start = article.get("page_start", 0)
|
||||||
|
page_end = article.get("page_end", 0)
|
||||||
|
page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}"
|
||||||
|
|
||||||
|
unified_summary += f"{article_label}"
|
||||||
|
if article_title:
|
||||||
|
unified_summary += f": {article_title}"
|
||||||
|
unified_summary += f" ({page_range})\n"
|
||||||
|
if article_text:
|
||||||
|
preview = article_text[:500] + "..." if len(article_text) > 500 else article_text
|
||||||
|
unified_summary += f"{preview}\n\n"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bauzone": bauzone,
|
||||||
|
"gemeinde": {
|
||||||
|
"id": gemeinde_obj.id,
|
||||||
|
"label": gemeinde_obj.label,
|
||||||
|
"plz": gemeinde_obj.plz
|
||||||
|
},
|
||||||
|
"extracted_content": {
|
||||||
|
"zones": relevant_zones,
|
||||||
|
"rules": relevant_rules,
|
||||||
|
"articles": relevant_articles,
|
||||||
|
"zone_parameter_tables": _filter_tables_by_bauzone(
|
||||||
|
all_extracted_content.get("zone_parameter_tables", []),
|
||||||
|
bauzone
|
||||||
|
),
|
||||||
|
"total_zones": len(all_extracted_content.get("zones", [])),
|
||||||
|
"total_rules": len(all_extracted_content.get("rules", [])),
|
||||||
|
"total_articles": len(all_extracted_content.get("articles", [])),
|
||||||
|
"total_tables": len(all_extracted_content.get("zone_parameter_tables", []))
|
||||||
|
},
|
||||||
|
"ai_summary": unified_summary,
|
||||||
|
"relevant_rules": relevant_rules,
|
||||||
|
"documents_processed": documents_processed,
|
||||||
|
"errors": all_extracted_content.get("errors", []),
|
||||||
|
"warnings": all_extracted_content.get("warnings", []),
|
||||||
|
"machbarkeitsstudie": bzo_params_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error extracting BZO information: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter rules by Bauzone code. Only keeps rules from SINGLE-zone articles to avoid
|
||||||
|
wrong values (e.g. article with W2,W3,W5 has different values per zone - we cannot
|
||||||
|
associate a rule value with a specific zone from article text alone).
|
||||||
|
"""
|
||||||
|
relevant_rules = []
|
||||||
|
bauzone_upper = bauzone.upper()
|
||||||
|
|
||||||
|
def _zone_matches(z: str) -> bool:
|
||||||
|
zu = (z or "").upper().strip()
|
||||||
|
if not zu:
|
||||||
|
return False
|
||||||
|
if bauzone_upper in zu:
|
||||||
|
return True
|
||||||
|
if zu in bauzone_upper and len(zu) >= 2:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
table_zones = rule.get("table_zones", []) or []
|
||||||
|
zone_raw = rule.get("zone_raw")
|
||||||
|
|
||||||
|
has_zone = bool(zone_raw) or bool(table_zones)
|
||||||
|
if not has_zone:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(table_zones) > 1:
|
||||||
|
matches_all = all(_zone_matches(str(z)) for z in table_zones)
|
||||||
|
if not matches_all:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matches = False
|
||||||
|
if zone_raw and _zone_matches(zone_raw):
|
||||||
|
matches = True
|
||||||
|
if not matches and table_zones:
|
||||||
|
for tz in table_zones:
|
||||||
|
if _zone_matches(str(tz)):
|
||||||
|
matches = True
|
||||||
|
break
|
||||||
|
if not matches:
|
||||||
|
ts = (rule.get("text_snippet") or "").upper()
|
||||||
|
if bauzone_upper in ts and len(table_zones) <= 1:
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
relevant_rules.append(rule)
|
||||||
|
|
||||||
|
logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total (multi-zone articles excluded)")
|
||||||
|
return relevant_rules
|
||||||
|
|
||||||
|
|
||||||
|
def filter_zones_by_bauzone(zones: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter zones by Bauzone code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zones: List of zone dictionaries from extraction
|
||||||
|
bauzone: Bauzone code to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of zones that match the Bauzone
|
||||||
|
"""
|
||||||
|
relevant_zones = []
|
||||||
|
bauzone_upper = bauzone.upper()
|
||||||
|
|
||||||
|
for zone in zones:
|
||||||
|
zone_code = zone.get("zone_code", "")
|
||||||
|
if bauzone_upper in zone_code.upper():
|
||||||
|
relevant_zones.append(zone)
|
||||||
|
|
||||||
|
logger.info(f"Filtered {len(relevant_zones)} zones for Bauzone {bauzone} from {len(zones)} total zones")
|
||||||
|
return relevant_zones
|
||||||
|
|
||||||
|
|
||||||
|
def filter_articles_by_bauzone(articles: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter articles that mention the Bauzone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
articles: List of article dictionaries from extraction
|
||||||
|
bauzone: Bauzone code to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of articles that mention the Bauzone
|
||||||
|
"""
|
||||||
|
relevant_articles = []
|
||||||
|
bauzone_upper = bauzone.upper()
|
||||||
|
|
||||||
|
for article in articles:
|
||||||
|
text = article.get("text", "")
|
||||||
|
zone_raw = article.get("zone_raw")
|
||||||
|
|
||||||
|
text_matches = bauzone_upper in text.upper() if text else False
|
||||||
|
zone_matches = bauzone_upper in zone_raw.upper() if zone_raw else False
|
||||||
|
|
||||||
|
if text_matches or zone_matches:
|
||||||
|
relevant_articles.append(article)
|
||||||
|
|
||||||
|
logger.info(f"Filtered {len(relevant_articles)} articles for Bauzone {bauzone} from {len(articles)} total articles")
|
||||||
|
return relevant_articles
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_tables_by_bauzone(tables: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter zone-parameter tables to include only those containing the specified Bauzone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tables: List of zone-parameter table dictionaries
|
||||||
|
bauzone: Bauzone code to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of tables containing the Bauzone
|
||||||
|
"""
|
||||||
|
relevant_tables = []
|
||||||
|
bauzone_upper = bauzone.upper()
|
||||||
|
|
||||||
|
for table in tables:
|
||||||
|
zones = table.get("zones", [])
|
||||||
|
matching_zones = [z for z in zones if bauzone_upper in str(z).upper()]
|
||||||
|
|
||||||
|
if matching_zones:
|
||||||
|
filtered_table = {
|
||||||
|
"page": table.get("page"),
|
||||||
|
"zones": matching_zones,
|
||||||
|
"parameters": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for param in table.get("parameters", []):
|
||||||
|
values_by_zone = param.get("values_by_zone", {})
|
||||||
|
filtered_values = {
|
||||||
|
zone: values_by_zone[zone]
|
||||||
|
for zone in matching_zones
|
||||||
|
if zone in values_by_zone
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered_values:
|
||||||
|
filtered_table["parameters"].append({
|
||||||
|
"parameter": param.get("parameter"),
|
||||||
|
"values_by_zone": filtered_values
|
||||||
|
})
|
||||||
|
|
||||||
|
if filtered_table["parameters"]:
|
||||||
|
relevant_tables.append(filtered_table)
|
||||||
|
|
||||||
|
logger.info(f"Filtered {len(relevant_tables)} tables for Bauzone {bauzone} from {len(tables)} total tables")
|
||||||
|
return relevant_tables
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_bauzone_ai_summary(
|
||||||
|
currentUser: User,
|
||||||
|
bauzone: str,
|
||||||
|
gemeinde: str,
|
||||||
|
extracted_content: Dict[str, Any],
|
||||||
|
relevant_rules: List[Dict[str, Any]],
|
||||||
|
relevant_zones: List[Dict[str, Any]],
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Use AI to generate a summary of relevant information for a Bauzone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current authenticated user
|
||||||
|
bauzone: Bauzone code
|
||||||
|
gemeinde: Gemeinde name
|
||||||
|
extracted_content: All extracted content from PDFs
|
||||||
|
relevant_rules: Rules filtered by Bauzone
|
||||||
|
relevant_zones: Zones filtered by Bauzone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AI-generated summary string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
services = getServices(
|
||||||
|
currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
aiService = services.ai
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
zone_parameter_tables = extracted_content.get("zone_parameter_tables", [])
|
||||||
|
table_values_for_bauzone = []
|
||||||
|
|
||||||
|
if zone_parameter_tables:
|
||||||
|
context_parts.append("=== BUILDING REGULATIONS TABLE VALUES FOR BAUZONE (INCLUDE THESE EXACT VALUES IN YOUR SUMMARY) ===")
|
||||||
|
for table in zone_parameter_tables:
|
||||||
|
page_num = table.get("page", 0)
|
||||||
|
article_ref = table.get("article", "Unknown article")
|
||||||
|
zones_in_table = table.get("zones", [])
|
||||||
|
|
||||||
|
matching_zones = [z for z in zones_in_table if bauzone.upper() in str(z).upper()]
|
||||||
|
|
||||||
|
if matching_zones:
|
||||||
|
context_parts.append(f"\nTabelle aus {article_ref} (Seite {page_num}):")
|
||||||
|
|
||||||
|
for param in table.get("parameters", []):
|
||||||
|
param_name = param.get("parameter", "")
|
||||||
|
values_by_zone = param.get("values_by_zone", {})
|
||||||
|
|
||||||
|
for zone, values in values_by_zone.items():
|
||||||
|
if bauzone.upper() in zone.upper():
|
||||||
|
if isinstance(values, list) and len(values) > 0:
|
||||||
|
val_entry = values[0]
|
||||||
|
value = val_entry.get("value", "")
|
||||||
|
unit = val_entry.get("unit", "")
|
||||||
|
unit_str = f" {unit}" if unit else ""
|
||||||
|
|
||||||
|
formatted_param = param_name
|
||||||
|
if "Ausnützungsziffer" in param_name or "ausnützungsziffer" in param_name.lower():
|
||||||
|
formatted_param = "Ausnützungsziffer max."
|
||||||
|
elif "Vollgeschosse" in param_name or "vollgeschosse" in param_name.lower():
|
||||||
|
formatted_param = "Vollgeschosse max."
|
||||||
|
elif "Gebäudelänge" in param_name or "gebäudelänge" in param_name.lower():
|
||||||
|
formatted_param = "Gebäudelänge max."
|
||||||
|
elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Grundabstand" in param_name or "grundabstand" in param_name.lower()):
|
||||||
|
formatted_param = "Grenzabstand - Grundabstand min."
|
||||||
|
elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Mehrlängen" in param_name or "mehrlängen" in param_name.lower()):
|
||||||
|
formatted_param = "Grenzabstand - Mehrlängen-zuschlag"
|
||||||
|
elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Höchstmass" in param_name or "höchstmass" in param_name.lower() or "Höchstmaß" in param_name):
|
||||||
|
formatted_param = "Grenzabstand - Höchstmass max."
|
||||||
|
elif "Fassadenhöhen" in param_name or "fassadenhöhen" in param_name.lower():
|
||||||
|
formatted_param = "Fassadenhöhen max."
|
||||||
|
elif "Dachgeschosse" in param_name or "dachgeschosse" in param_name.lower():
|
||||||
|
formatted_param = "anrechenbare Dachgeschosse max."
|
||||||
|
elif "Attikageschoss" in param_name or "attikageschoss" in param_name.lower():
|
||||||
|
formatted_param = "anrechenbares Attikageschoss max."
|
||||||
|
elif "Untergeschoss" in param_name or "untergeschoss" in param_name.lower():
|
||||||
|
formatted_param = "anrechenbares Untergeschoss max."
|
||||||
|
|
||||||
|
table_values_for_bauzone.append({
|
||||||
|
"parameter": formatted_param,
|
||||||
|
"value": value,
|
||||||
|
"unit": unit_str,
|
||||||
|
"article": article_ref,
|
||||||
|
"page": page_num
|
||||||
|
})
|
||||||
|
context_parts.append(f" • {formatted_param}: {value}{unit_str} (Quelle: {article_ref}, Seite {page_num})")
|
||||||
|
|
||||||
|
if len(values) > 1:
|
||||||
|
for idx, val_entry in enumerate(values[1:], 1):
|
||||||
|
value_extra = val_entry.get("value", "")
|
||||||
|
unit_extra = val_entry.get("unit", "")
|
||||||
|
unit_str_extra = f" {unit_extra}" if unit_extra else ""
|
||||||
|
context_parts.append(f" (Alternative: {value_extra}{unit_str_extra})")
|
||||||
|
|
||||||
|
if relevant_zones:
|
||||||
|
context_parts.append("\n=== ZONE DEFINITIONS ===")
|
||||||
|
for zone in relevant_zones:
|
||||||
|
zone_code = zone.get("zone_code", "")
|
||||||
|
zone_name = zone.get("zone_name", "")
|
||||||
|
zone_category = zone.get("zone_category", "")
|
||||||
|
geschosszahl = zone.get("geschosszahl")
|
||||||
|
gewerbeerleichterung = zone.get("gewerbeerleichterung", False)
|
||||||
|
page_num = zone.get("page", 0)
|
||||||
|
source_article = zone.get("source_article", "")
|
||||||
|
|
||||||
|
zone_info = f"- {zone_code}: {zone_name}"
|
||||||
|
if zone_category:
|
||||||
|
zone_info += f" (Kategorie: {zone_category})"
|
||||||
|
if geschosszahl:
|
||||||
|
zone_info += f", Geschosszahl: {geschosszahl}"
|
||||||
|
if gewerbeerleichterung:
|
||||||
|
zone_info += ", Gewerbeerleichterung: Ja"
|
||||||
|
if source_article:
|
||||||
|
zone_info += f" - Quelle: {source_article} (Seite {page_num})"
|
||||||
|
context_parts.append(zone_info)
|
||||||
|
|
||||||
|
relevant_articles = filter_articles_by_bauzone(extracted_content.get("articles", []), bauzone)
|
||||||
|
if relevant_articles:
|
||||||
|
context_parts.append("\n=== RELEVANT ARTICLES (full content) ===")
|
||||||
|
for article in relevant_articles:
|
||||||
|
article_label = article.get("article_label", "")
|
||||||
|
article_title = article.get("article_title", "")
|
||||||
|
article_text = article.get("text", "")
|
||||||
|
page_start = article.get("page_start", 0)
|
||||||
|
page_end = article.get("page_end", 0)
|
||||||
|
page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}"
|
||||||
|
|
||||||
|
context_parts.append(f"\n{article_label}: {article_title or 'Kein Titel'}")
|
||||||
|
context_parts.append(f"Lage: {page_range}")
|
||||||
|
if len(article_text) > 1000:
|
||||||
|
context_parts.append(f"Inhalt: {article_text[:1000]}...")
|
||||||
|
else:
|
||||||
|
context_parts.append(f"Inhalt: {article_text}")
|
||||||
|
|
||||||
|
if relevant_rules:
|
||||||
|
table_parameter_names = set()
|
||||||
|
for table in zone_parameter_tables:
|
||||||
|
for param in table.get("parameters", []):
|
||||||
|
param_name = param.get("parameter", "").lower()
|
||||||
|
table_parameter_names.add(param_name)
|
||||||
|
|
||||||
|
unique_rules = []
|
||||||
|
for rule in relevant_rules[:15]:
|
||||||
|
rule_type = rule.get("rule_type", "").lower()
|
||||||
|
if not any(tp in rule_type for tp in table_parameter_names):
|
||||||
|
unique_rules.append(rule)
|
||||||
|
|
||||||
|
if unique_rules:
|
||||||
|
context_parts.append("\n=== ADDITIONAL BUILDING REGULATIONS (from text) ===")
|
||||||
|
for rule in unique_rules[:8]:
|
||||||
|
rule_type = rule.get("rule_type", "")
|
||||||
|
value_numeric = rule.get("value_numeric")
|
||||||
|
value_text = rule.get("value_text", "")
|
||||||
|
unit = rule.get("unit", "")
|
||||||
|
page_num = rule.get("page", 0)
|
||||||
|
|
||||||
|
rule_desc = f"- {rule_type}: "
|
||||||
|
if value_numeric is not None:
|
||||||
|
rule_desc += f"{value_numeric}"
|
||||||
|
if unit:
|
||||||
|
rule_desc += f" {unit}"
|
||||||
|
else:
|
||||||
|
rule_desc += value_text
|
||||||
|
rule_desc += f" (Seite {page_num})"
|
||||||
|
|
||||||
|
context_parts.append(rule_desc)
|
||||||
|
|
||||||
|
context = "\n".join(context_parts)
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Analyze the following building zone (Bauzone) information extracted from BZO (Bau- und Zonenordnung) documents for {gemeinde}, specifically for Bauzone {bauzone}.
|
||||||
|
|
||||||
|
Extracted Content:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
CRITICAL INSTRUCTIONS:
|
||||||
|
1. You MUST include ALL actual values from the tables in your summary - do NOT just say "see tables on page X"
|
||||||
|
2. List ALL parameters with their actual values: Ausnützungsziffer, Vollgeschosse, Gebäudelänge, Grenzabstand (Grundabstand, Mehrlängen-zuschlag, Höchstmass), Fassadenhöhen, etc.
|
||||||
|
3. Integrate zone definitions and article information INTO the summary text - do NOT create separate sections
|
||||||
|
4. Always cite WHERE each piece of information was found (article number and page number)
|
||||||
|
5. Combine everything into ONE unified, flowing summary - no separate sections for zones/articles
|
||||||
|
6. Be comprehensive - include all relevant details from zones, articles, and tables
|
||||||
|
7. Format as a single, well-structured German text document
|
||||||
|
|
||||||
|
Please provide a comprehensive, unified summary that includes:
|
||||||
|
|
||||||
|
1. General description of Bauzone {bauzone}:
|
||||||
|
- Zone category (Wohnzonen, Zentrumszonen, etc.)
|
||||||
|
- Geschosszahl (number of full storeys)
|
||||||
|
- Gewerbeerleichterung status (Ja/Nein)
|
||||||
|
- Where defined (article and page number)
|
||||||
|
|
||||||
|
2. ALL building regulations with ACTUAL VALUES from tables (you MUST include the exact values):
|
||||||
|
- Ausnützungsziffer max.: [ACTUAL PERCENTAGE VALUE]% (from article, page)
|
||||||
|
- Vollgeschosse max.: [ACTUAL NUMBER] (from article, page)
|
||||||
|
- anrechenbare Dachgeschosse max.: [ACTUAL NUMBER] (from article, page)
|
||||||
|
- anrechenbares Attikageschoss max.: [ACTUAL NUMBER] (from article, page)
|
||||||
|
- anrechenbares Untergeschoss max.: [ACTUAL NUMBER] (from article, page)
|
||||||
|
- Gebäudelänge max.: [ACTUAL VALUE] m (from article, page)
|
||||||
|
- Grenzabstand - Grundabstand min.: [ACTUAL VALUE] m (from article, page)
|
||||||
|
- Grenzabstand - Mehrlängen-zuschlag: [ACTUAL FRACTION] (from article, page)
|
||||||
|
- Grenzabstand - Höchstmass max.: [ACTUAL VALUE] m (from article, page)
|
||||||
|
- Fassadenhöhen max.: [ACTUAL VALUE] m (from article, page, include footnote values if present)
|
||||||
|
|
||||||
|
3. Zone definitions: Integrate information about where this zone is defined (which articles mention it, with page numbers)
|
||||||
|
|
||||||
|
4. Relevant articles: Integrate key content from relevant articles naturally into the summary, citing article numbers and page numbers
|
||||||
|
|
||||||
|
5. Special conditions: Any special requirements or exceptions mentioned in articles
|
||||||
|
|
||||||
|
CRITICAL: You MUST include the actual numeric values from the tables in your summary. Do NOT say "see tables" - list the actual values. Format everything as ONE unified, flowing German text document without separate sections. Integrate zones and articles naturally into the narrative.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info(f"Generating AI summary for Bauzone {bauzone} in {gemeinde}")
|
||||||
|
ai_response = await aiService.callAiPlanning(
|
||||||
|
prompt=prompt,
|
||||||
|
debugType="bzo_summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ai_response.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating AI summary: {str(e)}", exc_info=True)
|
||||||
|
return f"Summary generation failed: {str(e)}. Found {len(relevant_rules)} relevant rules and {len(relevant_zones)} zones for Bauzone {bauzone}."
|
||||||
817
modules/features/realEstate/serviceGeometry.py
Normal file
817
modules/features/realEstate/serviceGeometry.py
Normal file
|
|
@ -0,0 +1,817 @@
|
||||||
|
"""
|
||||||
|
Real Estate feature — Geometry utilities.
|
||||||
|
|
||||||
|
Handles conversion between GeoPolylinie and Shapely polygons, combining
|
||||||
|
parcel geometries, filtering neighbor parcels, fetching parcel polygons
|
||||||
|
from Swisstopo, creating projects with parcel data, and GeoJSON conversion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
from .datamodelFeatureRealEstate import (
|
||||||
|
Projekt,
|
||||||
|
Parzelle,
|
||||||
|
StatusProzess,
|
||||||
|
GeoPolylinie,
|
||||||
|
GeoPunkt,
|
||||||
|
Kontext,
|
||||||
|
Gemeinde,
|
||||||
|
Kanton,
|
||||||
|
Land,
|
||||||
|
)
|
||||||
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
|
from modules.datamodels.datamodelUam import User
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def geopolylinie_to_shapely_polygon(geopolylinie: GeoPolylinie) -> Polygon:
|
||||||
|
"""
|
||||||
|
Convert GeoPolylinie to Shapely Polygon.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
geopolylinie: GeoPolylinie instance with punkte list
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shapely Polygon object
|
||||||
|
"""
|
||||||
|
if not geopolylinie or not geopolylinie.punkte:
|
||||||
|
raise ValueError("GeoPolylinie must have at least one point")
|
||||||
|
|
||||||
|
coordinates = []
|
||||||
|
for punkt in geopolylinie.punkte:
|
||||||
|
coordinates.append((punkt.x, punkt.y))
|
||||||
|
|
||||||
|
if len(coordinates) < 3:
|
||||||
|
raise ValueError("Polygon must have at least 3 points")
|
||||||
|
|
||||||
|
if coordinates[0] != coordinates[-1]:
|
||||||
|
coordinates.append(coordinates[0])
|
||||||
|
|
||||||
|
return Polygon(coordinates)
|
||||||
|
|
||||||
|
|
||||||
|
def shapely_polygon_to_geopolylinie(polygon: Polygon) -> GeoPolylinie:
|
||||||
|
"""
|
||||||
|
Convert Shapely Polygon to GeoPolylinie.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
polygon: Shapely Polygon object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeoPolylinie instance with LV95 coordinate system
|
||||||
|
"""
|
||||||
|
if not polygon or polygon.is_empty:
|
||||||
|
raise ValueError("Polygon must not be empty")
|
||||||
|
|
||||||
|
exterior_coords = list(polygon.exterior.coords)
|
||||||
|
|
||||||
|
if len(exterior_coords) > 1 and exterior_coords[0] == exterior_coords[-1]:
|
||||||
|
exterior_coords = exterior_coords[:-1]
|
||||||
|
|
||||||
|
punkte = []
|
||||||
|
for coord in exterior_coords:
|
||||||
|
punkt = GeoPunkt(
|
||||||
|
koordinatensystem="LV95",
|
||||||
|
x=float(coord[0]),
|
||||||
|
y=float(coord[1]),
|
||||||
|
z=None
|
||||||
|
)
|
||||||
|
punkte.append(punkt)
|
||||||
|
|
||||||
|
return GeoPolylinie(
|
||||||
|
closed=True,
|
||||||
|
punkte=punkte
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def combine_parcel_geometries(geometries: List[GeoPolylinie]) -> GeoPolylinie:
|
||||||
|
"""
|
||||||
|
Combine multiple parcel geometries into a single outer outline.
|
||||||
|
|
||||||
|
Uses Shapely union operation to merge polygons and automatically
|
||||||
|
removes internal edges. The result is a clean outer boundary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
geometries: List of GeoPolylinie instances to combine
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined GeoPolylinie representing the outer outline
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If geometries list is empty or invalid
|
||||||
|
"""
|
||||||
|
if not geometries or len(geometries) == 0:
|
||||||
|
raise ValueError("At least one geometry is required")
|
||||||
|
|
||||||
|
if len(geometries) == 1:
|
||||||
|
return geometries[0]
|
||||||
|
|
||||||
|
shapely_polygons = []
|
||||||
|
for geo in geometries:
|
||||||
|
try:
|
||||||
|
polygon = geopolylinie_to_shapely_polygon(geo)
|
||||||
|
if not polygon.is_empty:
|
||||||
|
shapely_polygons.append(polygon)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error converting geometry to Shapely Polygon: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not shapely_polygons:
|
||||||
|
raise ValueError("No valid geometries to combine")
|
||||||
|
|
||||||
|
if len(shapely_polygons) == 1:
|
||||||
|
return shapely_polygon_to_geopolylinie(shapely_polygons[0])
|
||||||
|
|
||||||
|
try:
|
||||||
|
combined = unary_union(shapely_polygons)
|
||||||
|
|
||||||
|
if hasattr(combined, 'geoms'):
|
||||||
|
largest = max(combined.geoms, key=lambda p: p.area)
|
||||||
|
combined = largest
|
||||||
|
|
||||||
|
if combined.is_empty:
|
||||||
|
raise ValueError("Union resulted in empty geometry")
|
||||||
|
|
||||||
|
result = shapely_polygon_to_geopolylinie(combined)
|
||||||
|
logger.info(f"Combined {len(geometries)} geometries into single outline with {len(result.punkte)} points")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error combining geometries: {e}", exc_info=True)
|
||||||
|
raise ValueError(f"Failed to combine geometries: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def filter_neighbor_parcels(
|
||||||
|
neighbors: List[Dict[str, Any]],
|
||||||
|
selected_geometries: List[GeoPolylinie]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Filter neighbor parcels to exclude those that are part of the selected parcels.
|
||||||
|
|
||||||
|
Uses geometric comparison to check if neighbor parcels intersect or touch
|
||||||
|
any of the selected parcel geometries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
neighbors: List of neighbor parcel dictionaries (must have 'perimeter' or 'geometry_geojson')
|
||||||
|
selected_geometries: List of GeoPolylinie instances representing selected parcels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list of neighbor parcels (excluding selected ones)
|
||||||
|
"""
|
||||||
|
if not neighbors or not selected_geometries:
|
||||||
|
return neighbors
|
||||||
|
|
||||||
|
selected_polygons = []
|
||||||
|
for geo in selected_geometries:
|
||||||
|
try:
|
||||||
|
polygon = geopolylinie_to_shapely_polygon(geo)
|
||||||
|
if not polygon.is_empty:
|
||||||
|
selected_polygons.append(polygon)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error converting selected geometry for filtering: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not selected_polygons:
|
||||||
|
return neighbors
|
||||||
|
|
||||||
|
filtered_neighbors = []
|
||||||
|
for neighbor in neighbors:
|
||||||
|
try:
|
||||||
|
neighbor_geometry = None
|
||||||
|
|
||||||
|
if neighbor.get("perimeter"):
|
||||||
|
perimeter = neighbor["perimeter"]
|
||||||
|
if isinstance(perimeter, dict) and perimeter.get("punkte"):
|
||||||
|
punkte = []
|
||||||
|
for p in perimeter["punkte"]:
|
||||||
|
punkt = GeoPunkt(
|
||||||
|
koordinatensystem=p.get("koordinatensystem", "LV95"),
|
||||||
|
x=float(p.get("x", 0)),
|
||||||
|
y=float(p.get("y", 0)),
|
||||||
|
z=p.get("z")
|
||||||
|
)
|
||||||
|
punkte.append(punkt)
|
||||||
|
neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte)
|
||||||
|
|
||||||
|
elif neighbor.get("geometry_geojson"):
|
||||||
|
geo_json = neighbor["geometry_geojson"]
|
||||||
|
geometry = geo_json.get("geometry") if isinstance(geo_json, dict) else geo_json
|
||||||
|
|
||||||
|
if geometry and geometry.get("type") == "Polygon":
|
||||||
|
coordinates = geometry.get("coordinates", [])
|
||||||
|
if coordinates and len(coordinates) > 0:
|
||||||
|
ring = coordinates[0]
|
||||||
|
punkte = []
|
||||||
|
for coord in ring:
|
||||||
|
if len(coord) >= 2:
|
||||||
|
punkt = GeoPunkt(
|
||||||
|
koordinatensystem="LV95",
|
||||||
|
x=float(coord[0]),
|
||||||
|
y=float(coord[1]),
|
||||||
|
z=float(coord[2]) if len(coord) > 2 else None
|
||||||
|
)
|
||||||
|
punkte.append(punkt)
|
||||||
|
neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte)
|
||||||
|
|
||||||
|
if not neighbor_geometry:
|
||||||
|
filtered_neighbors.append(neighbor)
|
||||||
|
continue
|
||||||
|
|
||||||
|
neighbor_polygon = geopolylinie_to_shapely_polygon(neighbor_geometry)
|
||||||
|
|
||||||
|
is_selected = False
|
||||||
|
for selected_polygon in selected_polygons:
|
||||||
|
if neighbor_polygon.intersects(selected_polygon) or neighbor_polygon.touches(selected_polygon):
|
||||||
|
area_diff = abs(neighbor_polygon.area - selected_polygon.area)
|
||||||
|
if area_diff < 1.0:
|
||||||
|
is_selected = True
|
||||||
|
break
|
||||||
|
if neighbor_polygon.contains(selected_polygon) or selected_polygon.contains(neighbor_polygon):
|
||||||
|
is_selected = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_selected:
|
||||||
|
filtered_neighbors.append(neighbor)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Filtered out neighbor parcel {neighbor.get('id')} - part of selected parcels")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error filtering neighbor parcel {neighbor.get('id')}: {e}")
|
||||||
|
filtered_neighbors.append(neighbor)
|
||||||
|
|
||||||
|
logger.info(f"Filtered {len(neighbors)} neighbors to {len(filtered_neighbors)} (removed {len(neighbors) - len(filtered_neighbors)} selected parcels)")
|
||||||
|
return filtered_neighbors
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_parcel_polygon_from_swisstopo(
|
||||||
|
gemeinde: str,
|
||||||
|
parzellen_nr: str,
|
||||||
|
sr: int = 2056
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gemeinde: Name der Gemeinde (z.B. "Bern")
|
||||||
|
parzellen_nr: Parzellennummer (z.B. "1234")
|
||||||
|
sr: Koordinatensystem (2056=LV95, 4326=WGS84)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden
|
||||||
|
Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
|
||||||
|
feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr)
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo")
|
||||||
|
return None
|
||||||
|
|
||||||
|
geometry = feature.get("geometry", {})
|
||||||
|
if geometry.get("type") == "Polygon":
|
||||||
|
coordinates = geometry.get("coordinates", [])
|
||||||
|
if coordinates and len(coordinates) > 0:
|
||||||
|
ring = coordinates[0]
|
||||||
|
|
||||||
|
punkte = []
|
||||||
|
for coord in ring:
|
||||||
|
if len(coord) >= 2:
|
||||||
|
punkt = {
|
||||||
|
"koordinatensystem": "LV95" if sr == 2056 else "WGS84",
|
||||||
|
"x": coord[0],
|
||||||
|
"y": coord[1],
|
||||||
|
"z": coord[2] if len(coord) > 2 else None
|
||||||
|
}
|
||||||
|
punkte.append(punkt)
|
||||||
|
|
||||||
|
logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"closed": True,
|
||||||
|
"punkte": punkte
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def convert_geojson_to_geopolylinie(geometry_data: Dict[str, Any]) -> Optional[GeoPolylinie]:
|
||||||
|
"""Convert GeoJSON geometry to GeoPolylinie format."""
|
||||||
|
if not geometry_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "geometry" in geometry_data:
|
||||||
|
geometry_data = geometry_data["geometry"]
|
||||||
|
|
||||||
|
geometry_type = geometry_data.get("type")
|
||||||
|
coordinates = geometry_data.get("coordinates")
|
||||||
|
|
||||||
|
if not coordinates or geometry_type != "Polygon":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not coordinates or len(coordinates) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ring = coordinates[0]
|
||||||
|
|
||||||
|
punkte = []
|
||||||
|
for coord in ring:
|
||||||
|
if len(coord) >= 2:
|
||||||
|
punkt = GeoPunkt(
|
||||||
|
koordinatensystem="LV95",
|
||||||
|
x=float(coord[0]),
|
||||||
|
y=float(coord[1]),
|
||||||
|
z=float(coord[2]) if len(coord) > 2 else None
|
||||||
|
)
|
||||||
|
punkte.append(punkt)
|
||||||
|
|
||||||
|
if not punkte:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return GeoPolylinie(
|
||||||
|
closed=True,
|
||||||
|
punkte=punkte
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project_with_parcel_data(
|
||||||
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
|
projekt_label: str,
|
||||||
|
parzellen_data: List[Dict[str, Any]],
|
||||||
|
status_prozess: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a Projekt with one or more Parzellen from provided parcel data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current authenticated user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
|
projekt_label: Label for the Projekt
|
||||||
|
parzellen_data: List of dictionaries containing parcel information from request
|
||||||
|
status_prozess: Optional project status (defaults to "Eingang")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing created Projekt and list of Parzellen
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If Gemeinde or Kanton not found, or validation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
|
||||||
|
|
||||||
|
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
|
if not projekt_label:
|
||||||
|
raise ValueError("Projekt label is required")
|
||||||
|
|
||||||
|
if not parzellen_data or len(parzellen_data) == 0:
|
||||||
|
raise ValueError("At least one Parzelle data is required")
|
||||||
|
|
||||||
|
for idx, parzelle_data in enumerate(parzellen_data):
|
||||||
|
if not parzelle_data.get("perimeter"):
|
||||||
|
raise ValueError(f"Parzelle {idx + 1} perimeter is required")
|
||||||
|
|
||||||
|
# First pass: Collect all parcel geometries for neighbor filtering
|
||||||
|
all_parcel_geometries = []
|
||||||
|
for parzelle_data in parzellen_data:
|
||||||
|
perimeter = parzelle_data.get("perimeter")
|
||||||
|
if perimeter:
|
||||||
|
if isinstance(perimeter, dict):
|
||||||
|
if "punkte" in perimeter and "closed" in perimeter:
|
||||||
|
try:
|
||||||
|
geo_perimeter = GeoPolylinie(**perimeter)
|
||||||
|
all_parcel_geometries.append(geo_perimeter)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error converting perimeter to GeoPolylinie: {e}")
|
||||||
|
else:
|
||||||
|
converted = convert_geojson_to_geopolylinie(perimeter)
|
||||||
|
if converted:
|
||||||
|
all_parcel_geometries.append(converted)
|
||||||
|
elif isinstance(perimeter, GeoPolylinie):
|
||||||
|
all_parcel_geometries.append(perimeter)
|
||||||
|
|
||||||
|
created_parzellen = []
|
||||||
|
parcel_perimeters = []
|
||||||
|
|
||||||
|
for idx, parzelle_data in enumerate(parzellen_data):
|
||||||
|
logger.info(f"Processing Parzelle {idx + 1}/{len(parzellen_data)}")
|
||||||
|
|
||||||
|
parcel_label = parzelle_data.get("id") or parzelle_data.get("number") or parzelle_data.get("label") or "Unknown"
|
||||||
|
|
||||||
|
existing_parzellen = realEstateInterface.getParzellen(
|
||||||
|
recordFilter={"label": parcel_label, "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_parzellen and len(existing_parzellen) > 0:
|
||||||
|
existing_parzelle = existing_parzellen[0]
|
||||||
|
logger.info(f"Parzelle with label '{parcel_label}' already exists (ID: {existing_parzelle.id}), reusing it")
|
||||||
|
|
||||||
|
if existing_parzelle.perimeter:
|
||||||
|
parcel_perimeters.append(existing_parzelle.perimeter)
|
||||||
|
|
||||||
|
created_parzellen.append(existing_parzelle)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Parzelle with label '{parcel_label}' does not exist, creating new one")
|
||||||
|
|
||||||
|
gemeinde_id = None
|
||||||
|
canton_abk = parzelle_data.get("canton")
|
||||||
|
municipality_name = parzelle_data.get("municipality_name")
|
||||||
|
|
||||||
|
logger.debug(f"Resolving Gemeinde/Kanton: canton='{canton_abk}', municipality='{municipality_name}'")
|
||||||
|
|
||||||
|
if municipality_name and canton_abk:
|
||||||
|
canton_names = {
|
||||||
|
"ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz",
|
||||||
|
"OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg",
|
||||||
|
"SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen",
|
||||||
|
"AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen",
|
||||||
|
"GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin",
|
||||||
|
"VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Ensuring Land 'Schweiz' exists")
|
||||||
|
laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"})
|
||||||
|
if not laender:
|
||||||
|
logger.info("Creating Land 'Schweiz'")
|
||||||
|
land = Land(
|
||||||
|
mandateId=mandateId,
|
||||||
|
label="Schweiz",
|
||||||
|
abk="CH"
|
||||||
|
)
|
||||||
|
land = realEstateInterface.createLand(land)
|
||||||
|
logger.info(f"Created Land 'Schweiz' with ID: {land.id}")
|
||||||
|
else:
|
||||||
|
land = laender[0]
|
||||||
|
logger.debug(f"Found Land 'Schweiz' with ID: {land.id}")
|
||||||
|
|
||||||
|
logger.debug(f"Looking up Kanton with abk='{canton_abk}'")
|
||||||
|
kantone = realEstateInterface.getKantone(recordFilter={"abk": canton_abk})
|
||||||
|
logger.debug(f"Found {len(kantone)} Kanton(e) with abk='{canton_abk}'")
|
||||||
|
if not kantone:
|
||||||
|
logger.info(f"Kanton '{canton_abk}' not found, creating it")
|
||||||
|
kanton_label = canton_names.get(canton_abk, canton_abk)
|
||||||
|
kanton = Kanton(
|
||||||
|
mandateId=mandateId,
|
||||||
|
label=kanton_label,
|
||||||
|
abk=canton_abk,
|
||||||
|
id_land=land.id
|
||||||
|
)
|
||||||
|
kanton = realEstateInterface.createKanton(kanton)
|
||||||
|
logger.info(f"Created Kanton '{kanton_label}' ({canton_abk}) with ID: {kanton.id}")
|
||||||
|
else:
|
||||||
|
kanton = kantone[0]
|
||||||
|
logger.debug(f"Found Kanton: ID={kanton.id}, Label={kanton.label}, abk={kanton.abk}")
|
||||||
|
|
||||||
|
logger.debug(f"Looking up Gemeinde with label='{municipality_name}' and id_kanton='{kanton.id}'")
|
||||||
|
gemeinden = realEstateInterface.getGemeinden(
|
||||||
|
recordFilter={"label": municipality_name, "id_kanton": kanton.id}
|
||||||
|
)
|
||||||
|
logger.debug(f"Found {len(gemeinden)} Gemeinde(n) with label='{municipality_name}' and id_kanton='{kanton.id}'")
|
||||||
|
if not gemeinden:
|
||||||
|
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
|
||||||
|
gemeinde = Gemeinde(
|
||||||
|
mandateId=mandateId,
|
||||||
|
label=municipality_name,
|
||||||
|
id_kanton=kanton.id,
|
||||||
|
plz=parzelle_data.get("plz")
|
||||||
|
)
|
||||||
|
gemeinde = realEstateInterface.createGemeinde(gemeinde)
|
||||||
|
logger.info(f"Created Gemeinde '{municipality_name}' with ID: {gemeinde.id}")
|
||||||
|
else:
|
||||||
|
gemeinde = gemeinden[0]
|
||||||
|
logger.debug(f"Found Gemeinde: ID={gemeinde.id}, Label={gemeinde.label}")
|
||||||
|
|
||||||
|
gemeinde_id = gemeinde.id
|
||||||
|
logger.info(f"Resolved Gemeinde '{municipality_name}' to ID '{gemeinde_id}'")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Missing Gemeinde/Kanton data: municipality_name={municipality_name}, canton={canton_abk}")
|
||||||
|
|
||||||
|
alias_tags = []
|
||||||
|
if parzelle_data.get("egrid"):
|
||||||
|
alias_tags.append(parzelle_data["egrid"])
|
||||||
|
if parzelle_data.get("number") and parzelle_data["number"] != parzelle_data.get("id"):
|
||||||
|
alias_tags.append(parzelle_data["number"])
|
||||||
|
|
||||||
|
strasse_nr = None
|
||||||
|
plz = None
|
||||||
|
|
||||||
|
address = parzelle_data.get("address")
|
||||||
|
if address:
|
||||||
|
parts = address.split(",")
|
||||||
|
if len(parts) >= 1:
|
||||||
|
strasse_nr = parts[0].strip()
|
||||||
|
plz = parzelle_data.get("plz")
|
||||||
|
|
||||||
|
logger.debug(f"Parzelle {idx + 1} address data: strasse_nr='{strasse_nr}', plz='{plz}', full_address='{address}'")
|
||||||
|
|
||||||
|
if not strasse_nr and not plz:
|
||||||
|
logger.warning(f"No address data found for Parzelle {idx + 1} (label: {parcel_label})")
|
||||||
|
|
||||||
|
kontext_items = []
|
||||||
|
|
||||||
|
if parzelle_data.get("egrid"):
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="EGRID",
|
||||||
|
inhalt=parzelle_data["egrid"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if parzelle_data.get("identnd"):
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="IdentND",
|
||||||
|
inhalt=parzelle_data["identnd"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if parzelle_data.get("area_m2"):
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="Fläche",
|
||||||
|
inhalt=f"{parzelle_data['area_m2']} m²"
|
||||||
|
))
|
||||||
|
|
||||||
|
if parzelle_data.get("centroid"):
|
||||||
|
centroid = parzelle_data["centroid"]
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="Zentrum (LV95)",
|
||||||
|
inhalt=f"X: {centroid.get('x')} m, Y: {centroid.get('y')} m (EPSG:2056)"
|
||||||
|
))
|
||||||
|
|
||||||
|
if parzelle_data.get("geoportal_url"):
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="Geoportal URL",
|
||||||
|
inhalt=parzelle_data["geoportal_url"]
|
||||||
|
))
|
||||||
|
|
||||||
|
if parzelle_data.get("municipality_code"):
|
||||||
|
kontext_items.append(Kontext(
|
||||||
|
thema="BFS-Nummer",
|
||||||
|
inhalt=str(parzelle_data["municipality_code"])
|
||||||
|
))
|
||||||
|
|
||||||
|
adjacent_parcel_refs = []
|
||||||
|
if parzelle_data.get("adjacent_parcels"):
|
||||||
|
neighbors_to_filter = []
|
||||||
|
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||||
|
if isinstance(adj_parcel, dict):
|
||||||
|
neighbors_to_filter.append(adj_parcel)
|
||||||
|
elif isinstance(adj_parcel, str):
|
||||||
|
neighbors_to_filter.append({"id": adj_parcel})
|
||||||
|
|
||||||
|
if all_parcel_geometries and neighbors_to_filter:
|
||||||
|
try:
|
||||||
|
filtered_neighbors = filter_neighbor_parcels(
|
||||||
|
neighbors_to_filter,
|
||||||
|
all_parcel_geometries
|
||||||
|
)
|
||||||
|
for filtered_neighbor in filtered_neighbors:
|
||||||
|
adj_id = filtered_neighbor.get("id")
|
||||||
|
if adj_id:
|
||||||
|
adjacent_parcel_refs.append({"id": adj_id})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error filtering neighbor parcels: {e}, including all neighbors")
|
||||||
|
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||||
|
if isinstance(adj_parcel, dict):
|
||||||
|
adj_id = adj_parcel.get("id")
|
||||||
|
if adj_id:
|
||||||
|
adjacent_parcel_refs.append({"id": adj_id})
|
||||||
|
elif isinstance(adj_parcel, str):
|
||||||
|
adjacent_parcel_refs.append({"id": adj_parcel})
|
||||||
|
else:
|
||||||
|
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||||
|
if isinstance(adj_parcel, dict):
|
||||||
|
adj_id = adj_parcel.get("id")
|
||||||
|
if adj_id:
|
||||||
|
adjacent_parcel_refs.append({"id": adj_id})
|
||||||
|
elif isinstance(adj_parcel, str):
|
||||||
|
adjacent_parcel_refs.append({"id": adj_parcel})
|
||||||
|
|
||||||
|
perimeter = parzelle_data.get("perimeter")
|
||||||
|
if isinstance(perimeter, dict):
|
||||||
|
if "punkte" in perimeter and "closed" in perimeter:
|
||||||
|
try:
|
||||||
|
perimeter = GeoPolylinie(**perimeter)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid perimeter format: {str(e)}")
|
||||||
|
else:
|
||||||
|
converted = convert_geojson_to_geopolylinie(perimeter)
|
||||||
|
if converted:
|
||||||
|
perimeter = converted
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid perimeter format: cannot convert to GeoPolylinie")
|
||||||
|
elif isinstance(perimeter, GeoPolylinie):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid perimeter type: must be dict or GeoPolylinie")
|
||||||
|
|
||||||
|
baulinie = None
|
||||||
|
geometry = parzelle_data.get("geometry")
|
||||||
|
logger.debug(f"Geometry present: {geometry is not None}")
|
||||||
|
if geometry:
|
||||||
|
logger.debug(f"Geometry type: {type(geometry)}, keys: {list(geometry.keys()) if isinstance(geometry, dict) else 'not a dict'}")
|
||||||
|
baulinie = convert_geojson_to_geopolylinie(geometry)
|
||||||
|
if baulinie:
|
||||||
|
logger.info(f"Extracted baulinie from geometry with {len(baulinie.punkte)} points")
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to extract baulinie from geometry")
|
||||||
|
else:
|
||||||
|
logger.warning("No geometry found in parzelle_data")
|
||||||
|
|
||||||
|
parzelle_create_data = {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"label": parcel_label,
|
||||||
|
"parzellenAliasTags": alias_tags,
|
||||||
|
"eigentuemerschaft": None,
|
||||||
|
"strasseNr": strasse_nr,
|
||||||
|
"plz": plz,
|
||||||
|
"perimeter": perimeter,
|
||||||
|
"baulinie": baulinie,
|
||||||
|
"kontextGemeinde": gemeinde_id,
|
||||||
|
"bauzone": None,
|
||||||
|
"az": None,
|
||||||
|
"bz": None,
|
||||||
|
"vollgeschossZahl": None,
|
||||||
|
"anrechenbarDachgeschoss": None,
|
||||||
|
"anrechenbarUntergeschoss": None,
|
||||||
|
"gebaeudehoeheMax": None,
|
||||||
|
"regelnGrenzabstand": [],
|
||||||
|
"regelnMehrlaengenzuschlag": [],
|
||||||
|
"regelnMehrhoehenzuschlag": [],
|
||||||
|
"parzelleBebaut": None,
|
||||||
|
"parzelleErschlossen": None,
|
||||||
|
"parzelleHanglage": None,
|
||||||
|
"laermschutzzone": None,
|
||||||
|
"hochwasserschutzzone": None,
|
||||||
|
"grundwasserschutzzone": None,
|
||||||
|
"parzellenNachbarschaft": adjacent_parcel_refs,
|
||||||
|
"dokumente": [],
|
||||||
|
"kontextInformationen": kontext_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Creating Parzelle with label: {parzelle_create_data.get('label')}")
|
||||||
|
logger.debug(f"Parzelle mandateId: {parzelle_create_data.get('mandateId')}")
|
||||||
|
logger.debug(f"Parzelle perimeter present: {parzelle_create_data.get('perimeter') is not None}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parzelle_instance = Parzelle(**parzelle_create_data)
|
||||||
|
logger.debug(f"Parzelle instance created successfully with ID: {parzelle_instance.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating Parzelle instance: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Calling createParzelle for Parzelle '{parzelle_instance.label}' (ID: {parzelle_instance.id})")
|
||||||
|
logger.debug(f"Parzelle instance before createParzelle: {parzelle_instance.model_dump(mode='json', exclude={'perimeter', 'baulinie', 'kontextInformationen'})}")
|
||||||
|
|
||||||
|
parzelle_dict = parzelle_instance.model_dump(mode='json')
|
||||||
|
logger.debug(f"Parzelle dict keys: {list(parzelle_dict.keys())}")
|
||||||
|
|
||||||
|
created_parzelle = realEstateInterface.createParzelle(parzelle_instance)
|
||||||
|
|
||||||
|
logger.info(f"createParzelle returned: ID={created_parzelle.id if created_parzelle else 'None'}, Label={created_parzelle.label if created_parzelle else 'None'}")
|
||||||
|
|
||||||
|
if not created_parzelle:
|
||||||
|
raise ValueError("Failed to create Parzelle - createParzelle returned None")
|
||||||
|
|
||||||
|
if not created_parzelle.id:
|
||||||
|
raise ValueError("Failed to create Parzelle - no ID returned")
|
||||||
|
|
||||||
|
logger.info(f"Parzelle created with ID: {created_parzelle.id}")
|
||||||
|
|
||||||
|
logger.debug(f"Verifying Parzelle {created_parzelle.id} exists in database...")
|
||||||
|
verify_parzelle = realEstateInterface.getParzelle(created_parzelle.id)
|
||||||
|
if not verify_parzelle:
|
||||||
|
logger.error(f"Parzelle {created_parzelle.id} was not found in database after creation")
|
||||||
|
all_parzellen = realEstateInterface.getParzellen(recordFilter=None)
|
||||||
|
logger.error(f"Total Parzellen in database: {len(all_parzellen)}")
|
||||||
|
if all_parzellen:
|
||||||
|
logger.error(f"Sample Parzelle IDs: {[p.id for p in all_parzellen[:5]]}")
|
||||||
|
raise ValueError(f"Parzelle {created_parzelle.id} was not found in database after creation")
|
||||||
|
|
||||||
|
logger.info(f"Verified Parzelle {created_parzelle.id} exists in database")
|
||||||
|
created_parzelle = verify_parzelle
|
||||||
|
|
||||||
|
if created_parzelle.perimeter:
|
||||||
|
parcel_perimeters.append(created_parzelle.perimeter)
|
||||||
|
|
||||||
|
created_parzellen.append(created_parzelle)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating Parzelle {idx + 1}: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not created_parzellen:
|
||||||
|
raise ValueError("No Parzellen were successfully created")
|
||||||
|
|
||||||
|
logger.info(f"Successfully created {len(created_parzellen)} Parzelle(n)")
|
||||||
|
|
||||||
|
project_baulinie = None
|
||||||
|
if len(parcel_perimeters) > 0:
|
||||||
|
try:
|
||||||
|
if len(parcel_perimeters) == 1:
|
||||||
|
project_baulinie = parcel_perimeters[0]
|
||||||
|
logger.info("Using single parcel perimeter as baulinie")
|
||||||
|
else:
|
||||||
|
logger.info(f"Combining {len(parcel_perimeters)} parcel geometries to create baulinie")
|
||||||
|
project_baulinie = combine_parcel_geometries(parcel_perimeters)
|
||||||
|
logger.info(f"Created combined baulinie with {len(project_baulinie.punkte)} points")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error combining parcel geometries for baulinie: {e}", exc_info=True)
|
||||||
|
if parcel_perimeters:
|
||||||
|
project_baulinie = parcel_perimeters[0]
|
||||||
|
logger.warning("Using first parcel perimeter as fallback baulinie")
|
||||||
|
|
||||||
|
status_prozess_enum = None
|
||||||
|
if status_prozess:
|
||||||
|
try:
|
||||||
|
if isinstance(status_prozess, str):
|
||||||
|
status_prozess_enum = StatusProzess(status_prozess)
|
||||||
|
elif isinstance(status_prozess, StatusProzess):
|
||||||
|
status_prozess_enum = status_prozess
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
logger.warning(f"Invalid statusProzess '{status_prozess}', using default 'Eingang'")
|
||||||
|
status_prozess_enum = StatusProzess.EINGANG
|
||||||
|
else:
|
||||||
|
status_prozess_enum = StatusProzess.EINGANG
|
||||||
|
|
||||||
|
logger.debug(f"Preparing Projekt creation with baulinie: {project_baulinie is not None}")
|
||||||
|
if project_baulinie:
|
||||||
|
logger.debug(f"Baulinie has {len(project_baulinie.punkte)} points")
|
||||||
|
|
||||||
|
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
|
||||||
|
|
||||||
|
projekt_create_data = {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"label": projekt_label,
|
||||||
|
"statusProzess": status_prozess_enum,
|
||||||
|
"perimeter": project_perimeter,
|
||||||
|
"baulinie": project_baulinie,
|
||||||
|
"parzellen": created_parzellen,
|
||||||
|
"dokumente": [],
|
||||||
|
"kontextInformationen": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Projekt data prepared: label={projekt_label}, parzellen_count={len(projekt_create_data['parzellen'])}, baulinie={'present' if project_baulinie else 'None'}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
projekt_instance = Projekt(**projekt_create_data)
|
||||||
|
logger.debug(f"Projekt instance created successfully with ID: {projekt_instance.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating Projekt instance: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.debug(f"Creating Projekt with {len(projekt_instance.parzellen)} Parzelle(n)")
|
||||||
|
if projekt_instance.parzellen:
|
||||||
|
for idx, p in enumerate(projekt_instance.parzellen):
|
||||||
|
logger.debug(f" Parzelle {idx}: ID={p.id}, Label={p.label}")
|
||||||
|
|
||||||
|
logger.debug(f"Projekt baulinie before save: {projekt_instance.baulinie is not None}")
|
||||||
|
if projekt_instance.baulinie:
|
||||||
|
logger.debug(f"Projekt baulinie has {len(projekt_instance.baulinie.punkte)} points")
|
||||||
|
|
||||||
|
try:
|
||||||
|
created_projekt = realEstateInterface.createProjekt(projekt_instance)
|
||||||
|
logger.info(f"Created Projekt '{created_projekt.label}' (ID: {created_projekt.id})")
|
||||||
|
logger.debug(f"Created Projekt baulinie: {created_projekt.baulinie is not None}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calling createProjekt: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not created_projekt or not created_projekt.id:
|
||||||
|
raise ValueError("Failed to create Projekt - no ID returned")
|
||||||
|
|
||||||
|
if not created_projekt.parzellen or len(created_projekt.parzellen) == 0:
|
||||||
|
logger.warning(f"Projekt {created_projekt.id} created but no Parzellen linked")
|
||||||
|
verify_projekt = realEstateInterface.getProjekt(created_projekt.id)
|
||||||
|
if verify_projekt and verify_projekt.parzellen:
|
||||||
|
logger.info(f"Parzellen found when fetching Projekt from database: {len(verify_projekt.parzellen)}")
|
||||||
|
created_projekt = verify_projekt
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Projekt {created_projekt.id} has no Parzellen linked after creation")
|
||||||
|
else:
|
||||||
|
logger.info(f"Projekt {created_projekt.id} successfully linked to {len(created_projekt.parzellen)} Parzelle(n)")
|
||||||
|
for idx, p in enumerate(created_projekt.parzellen):
|
||||||
|
logger.debug(f" Linked Parzelle {idx}: ID={p.id if hasattr(p, 'id') else 'NO ID'}, Label={p.label if hasattr(p, 'label') else 'NO LABEL'}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"projekt": created_projekt.model_dump(),
|
||||||
|
"parzellen": [p.model_dump() for p in created_parzellen],
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating project with parcel data: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
File diff suppressed because it is too large
Load diff
305
modules/features/teamsbot/serviceCommands.py
Normal file
305
modules/features/teamsbot/serviceCommands.py
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Teamsbot Service — AI command execution logic.
|
||||||
|
|
||||||
|
Extracted from service.py. All functions accept `service` (a TeamsbotService
|
||||||
|
instance) as the first parameter so the class can delegate to them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
from .datamodelTeamsbot import (
|
||||||
|
TeamsbotTranscript,
|
||||||
|
TeamsbotCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _executeCommands(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
commands: List[TeamsbotCommand],
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
):
|
||||||
|
"""Execute structured commands returned by the AI."""
|
||||||
|
for cmd in commands:
|
||||||
|
action = cmd.action
|
||||||
|
params = cmd.params or {}
|
||||||
|
logger.info(f"Session {sessionId}: Executing command '{action}' with params {params}")
|
||||||
|
try:
|
||||||
|
if action == "toggleTranscript":
|
||||||
|
await _cmdToggleTranscript(service, sessionId, params, websocket)
|
||||||
|
elif action == "toggleChat":
|
||||||
|
await _cmdToggleChat(service, sessionId, params, websocket)
|
||||||
|
elif action == "sendChat":
|
||||||
|
await _cmdSendChat(service, sessionId, params, websocket)
|
||||||
|
elif action == "readChat":
|
||||||
|
await _cmdReadChat(service, sessionId, params, voiceInterface, websocket)
|
||||||
|
elif action == "readAloud":
|
||||||
|
await _cmdReadAloud(service, sessionId, params, voiceInterface, websocket)
|
||||||
|
elif action == "changeLanguage":
|
||||||
|
await _cmdChangeLanguage(service, sessionId, params)
|
||||||
|
elif action in ("toggleMic", "toggleCamera"):
|
||||||
|
await _cmdToggleMicOrCamera(service, sessionId, action, params, websocket)
|
||||||
|
elif action == "sendMail":
|
||||||
|
await _cmdSendMail(service, sessionId, params)
|
||||||
|
elif action == "storeDocument":
|
||||||
|
await _cmdStoreDocument(service, sessionId, params)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Session {sessionId}: Unknown command '{action}'")
|
||||||
|
except Exception as cmdErr:
|
||||||
|
logger.warning(f"Session {sessionId}: Command '{action}' failed: {cmdErr}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdToggleTranscript(service, sessionId: str, params: dict, websocket: WebSocket):
|
||||||
|
"""Caption on/off - toggle Teams live transcript capture."""
|
||||||
|
enable = params.get("enable", True)
|
||||||
|
if websocket:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "botCommand",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"command": "toggleTranscript",
|
||||||
|
"params": {"enable": enable},
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdToggleChat(service, sessionId: str, params: dict, websocket: WebSocket):
|
||||||
|
"""Chat on/off - enable/disable meeting chat monitoring."""
|
||||||
|
enable = params.get("enable", True)
|
||||||
|
if websocket:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "botCommand",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"command": "toggleChat",
|
||||||
|
"params": {"enable": enable},
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdSendChat(service, sessionId: str, params: dict, websocket: WebSocket):
|
||||||
|
"""Send a message to the meeting chat and record it in transcript/SSE."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
|
||||||
|
chatText = params.get("text", "")
|
||||||
|
if not chatText:
|
||||||
|
return
|
||||||
|
if websocket:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "sendChatMessage",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"text": chatText,
|
||||||
|
}))
|
||||||
|
logger.info(f"Chat command sent for session {sessionId}")
|
||||||
|
|
||||||
|
from . import interfaceFeatureTeamsbot as interfaceDb
|
||||||
|
interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId)
|
||||||
|
|
||||||
|
transcriptData = TeamsbotTranscript(
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=service.config.botName,
|
||||||
|
text=chatText,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
confidence=1.0,
|
||||||
|
language=service.config.language,
|
||||||
|
isFinal=True,
|
||||||
|
source="chat",
|
||||||
|
).model_dump()
|
||||||
|
createdTranscript = interface.createTranscript(transcriptData)
|
||||||
|
|
||||||
|
import time
|
||||||
|
service._contextBuffer.append({
|
||||||
|
"speaker": service.config.botName,
|
||||||
|
"text": chatText,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"source": "chat",
|
||||||
|
})
|
||||||
|
service._lastTranscriptSpeaker = service.config.botName
|
||||||
|
service._lastTranscriptText = chatText
|
||||||
|
service._lastTranscriptId = createdTranscript.get("id")
|
||||||
|
service._lastBotResponseText = chatText.strip().lower()
|
||||||
|
service._lastBotResponseTs = time.time()
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "transcript", {
|
||||||
|
"id": createdTranscript.get("id"),
|
||||||
|
"speaker": service.config.botName,
|
||||||
|
"text": chatText,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"isContinuation": False,
|
||||||
|
"source": "chat",
|
||||||
|
"speakerResolvedFromHint": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdReadChat(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
params: dict,
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
):
|
||||||
|
"""Read chat messages (from DB) with optional fromdatetime/todatetime, then speak or send to chat."""
|
||||||
|
from .service import _speakTextChunked
|
||||||
|
from .serviceConversation import _summarizeForVoice
|
||||||
|
|
||||||
|
from . import interfaceFeatureTeamsbot as interfaceDb
|
||||||
|
interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId)
|
||||||
|
transcripts = interface.getTranscripts(sessionId)
|
||||||
|
fromDtRaw = params.get("fromdatetime") or params.get("fromDateTime")
|
||||||
|
toDtRaw = params.get("todatetime") or params.get("toDateTime")
|
||||||
|
fromTs = datetime.fromisoformat(fromDtRaw).replace(tzinfo=timezone.utc).timestamp() if fromDtRaw else None
|
||||||
|
toTs = datetime.fromisoformat(toDtRaw).replace(tzinfo=timezone.utc).timestamp() if toDtRaw else None
|
||||||
|
chatOnly = [t for t in transcripts if t.get("source") in ("chat", "chatHistory")]
|
||||||
|
if fromTs is not None:
|
||||||
|
chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) >= fromTs]
|
||||||
|
if toTs is not None:
|
||||||
|
chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) <= toTs]
|
||||||
|
summary = "\n".join(f"[{t.get('speaker', '?')}]: {t.get('text', '')}" for t in chatOnly[-20:])
|
||||||
|
if not summary:
|
||||||
|
summary = "Keine Chat-Nachrichten im angegebenen Zeitraum."
|
||||||
|
if voiceInterface and websocket:
|
||||||
|
spokenSummary = await _summarizeForVoice(service, sessionId, summary[:2000])
|
||||||
|
cancelHook = service._makeAnswerCancelHook()
|
||||||
|
async with service._meetingTtsLock:
|
||||||
|
await _speakTextChunked(
|
||||||
|
websocket=websocket,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
sessionId=sessionId,
|
||||||
|
voiceText=spokenSummary,
|
||||||
|
languageCode=service.config.language,
|
||||||
|
voiceName=service.config.voiceId,
|
||||||
|
isCancelled=cancelHook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdReadAloud(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
params: dict,
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
):
|
||||||
|
"""Read text aloud via TTS and play in meeting."""
|
||||||
|
from .service import _speakTextChunked, _voiceFriendlyMeetingText
|
||||||
|
|
||||||
|
readText = params.get("text", "")
|
||||||
|
if readText and voiceInterface and websocket:
|
||||||
|
cancelHook = service._makeAnswerCancelHook()
|
||||||
|
async with service._meetingTtsLock:
|
||||||
|
await _speakTextChunked(
|
||||||
|
websocket=websocket,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
sessionId=sessionId,
|
||||||
|
voiceText=_voiceFriendlyMeetingText(readText),
|
||||||
|
languageCode=service.config.language,
|
||||||
|
voiceName=service.config.voiceId,
|
||||||
|
isCancelled=cancelHook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdChangeLanguage(service, sessionId: str, params: dict):
|
||||||
|
"""Change bot language."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
|
||||||
|
newLang = params.get("language", "")
|
||||||
|
if newLang:
|
||||||
|
service.config = service.config.model_copy(update={"language": newLang})
|
||||||
|
logger.info(f"Session {sessionId}: Language changed to '{newLang}'")
|
||||||
|
await _emitSessionEvent(sessionId, "languageChanged", {"language": newLang})
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdToggleMicOrCamera(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
action: str,
|
||||||
|
params: dict,
|
||||||
|
websocket: WebSocket,
|
||||||
|
):
|
||||||
|
"""Toggle mic or camera in the meeting."""
|
||||||
|
if websocket:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "botCommand",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"command": action,
|
||||||
|
"params": params,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdSendMail(service, sessionId: str, params: dict):
|
||||||
|
"""Send email via Service Center MessagingService."""
|
||||||
|
recipient = params.get("recipient") or params.get("to", "")
|
||||||
|
subject = params.get("subject", "")
|
||||||
|
message = params.get("message") or params.get("body", "")
|
||||||
|
if not recipient or not subject:
|
||||||
|
logger.warning(f"Session {sessionId}: sendMail requires recipient and subject")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter import ServiceCenterContext, getService
|
||||||
|
ctx = ServiceCenterContext(
|
||||||
|
user=service.currentUser,
|
||||||
|
mandate_id=service.mandateId,
|
||||||
|
feature_instance_id=service.instanceId,
|
||||||
|
)
|
||||||
|
messaging = getService("messaging", ctx)
|
||||||
|
success = messaging.sendEmailDirect(
|
||||||
|
recipient=recipient,
|
||||||
|
subject=subject,
|
||||||
|
message=message,
|
||||||
|
userId=str(service.currentUser.id) if service.currentUser else None,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
logger.info(f"Session {sessionId}: Email sent to {recipient}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Session {sessionId}: Email send failed for {recipient}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Session {sessionId}: sendMail failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmdStoreDocument(service, sessionId: str, params: dict):
|
||||||
|
"""Store document via Service Center SharepointService."""
|
||||||
|
sitePath = params.get("sitePath") or params.get("site", "")
|
||||||
|
folderPath = params.get("folderPath") or params.get("folder", "")
|
||||||
|
fileName = params.get("fileName", "document.txt")
|
||||||
|
content = params.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
content = content.encode("utf-8")
|
||||||
|
if not sitePath or not folderPath:
|
||||||
|
logger.warning(f"Session {sessionId}: storeDocument requires sitePath and folderPath")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter import ServiceCenterContext, getService
|
||||||
|
ctx = ServiceCenterContext(
|
||||||
|
user=service.currentUser,
|
||||||
|
mandate_id=service.mandateId,
|
||||||
|
feature_instance_id=service.instanceId,
|
||||||
|
)
|
||||||
|
sharepoint = getService("sharepoint", ctx)
|
||||||
|
if not sharepoint.setAccessTokenFromConnection(service.currentUser):
|
||||||
|
logger.warning(f"Session {sessionId}: SharePoint connection not configured")
|
||||||
|
return
|
||||||
|
site = await sharepoint.getSiteByStandardPath(sitePath)
|
||||||
|
if not site:
|
||||||
|
logger.warning(f"Session {sessionId}: SharePoint site not found: {sitePath}")
|
||||||
|
return
|
||||||
|
result = await sharepoint.uploadFile(
|
||||||
|
siteId=site["id"],
|
||||||
|
folderPath=folderPath,
|
||||||
|
fileName=fileName,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
logger.warning(f"Session {sessionId}: storeDocument failed: {result['error']}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Session {sessionId}: Document stored: {fileName}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Session {sessionId}: storeDocument failed: {e}")
|
||||||
996
modules/features/teamsbot/serviceConversation.py
Normal file
996
modules/features/teamsbot/serviceConversation.py
Normal file
|
|
@ -0,0 +1,996 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Teamsbot Service — Conversation & AI analysis logic.
|
||||||
|
|
||||||
|
Extracted from service.py. All functions accept `service` (a TeamsbotService
|
||||||
|
instance) as the first parameter so the class can delegate to them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
from .datamodelTeamsbot import (
|
||||||
|
TeamsbotTranscript,
|
||||||
|
TeamsbotBotResponse,
|
||||||
|
TeamsbotResponseType,
|
||||||
|
TeamsbotResponseMode,
|
||||||
|
TeamsbotResponseChannel,
|
||||||
|
SpeechTeamsResponse,
|
||||||
|
TeamsbotDirectorPromptMode,
|
||||||
|
TeamsbotDirectorPromptStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyzeAndRespond(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
interface,
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
triggerTranscript: Dict[str, Any],
|
||||||
|
):
|
||||||
|
"""Run SPEECH_TEAMS AI analysis and respond if needed."""
|
||||||
|
from .service import (
|
||||||
|
_emitSessionEvent, createAiService, _speakTextChunked,
|
||||||
|
_voiceFriendlyMeetingText,
|
||||||
|
TEAMSBOT_AGENT_MAX_ROUNDS, TEAMSBOT_AGENT_MAX_COST_CHF,
|
||||||
|
)
|
||||||
|
|
||||||
|
if service._aiAnalysisInProgress:
|
||||||
|
logger.info(f"Session {sessionId}: AI analysis already in progress, skipping duplicate trigger")
|
||||||
|
return
|
||||||
|
if service._agentEscalationInFlight:
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Agent escalation still in flight — "
|
||||||
|
f"skipping new SPEECH_TEAMS trigger to prevent overlapping replies"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
service._aiAnalysisInProgress = True
|
||||||
|
service._lastAiCallTime = time.time()
|
||||||
|
|
||||||
|
contextLines = []
|
||||||
|
for segment in service._contextBuffer:
|
||||||
|
speaker = segment.get("speaker", "Unknown")
|
||||||
|
text = segment.get("text", "")
|
||||||
|
segSource = segment.get("source", "caption")
|
||||||
|
prefix = "Chat" if segSource == "chat" else ""
|
||||||
|
if service._isBotSpeaker(speaker):
|
||||||
|
contextLines.append(f"[YOU ({service.config.botName})]: {text}")
|
||||||
|
elif prefix:
|
||||||
|
contextLines.append(f"[{prefix}: {speaker}]: {text}")
|
||||||
|
else:
|
||||||
|
contextLines.append(f"[{speaker}]: {text}")
|
||||||
|
|
||||||
|
sessionContextStr = ""
|
||||||
|
if service._sessionContext:
|
||||||
|
sessionContextStr = f"\nSESSION_CONTEXT (background knowledge provided by the user):\n{service._sessionContext}\n"
|
||||||
|
|
||||||
|
summaryStr = ""
|
||||||
|
if service._contextSummary:
|
||||||
|
summaryStr = f"\nEARLIER_CONVERSATION_SUMMARY:\n{service._contextSummary}\n"
|
||||||
|
|
||||||
|
directorStr = service._buildPersistentDirectorContext()
|
||||||
|
|
||||||
|
transcriptContext = f"BOT_NAME:{service.config.botName}{sessionContextStr}{summaryStr}{directorStr}\nRECENT_TRANSCRIPT:\n" + "\n".join(contextLines)
|
||||||
|
|
||||||
|
try:
|
||||||
|
aiService = createAiService(service.currentUser, service.mandateId, service.instanceId)
|
||||||
|
await aiService.ensureAiObjectsInitialized()
|
||||||
|
|
||||||
|
request = AiCallRequest(
|
||||||
|
prompt=service.config.aiSystemPrompt,
|
||||||
|
context=transcriptContext,
|
||||||
|
options=AiCallOptions(
|
||||||
|
operationType=OperationTypeEnum.SPEECH_TEAMS,
|
||||||
|
priority=PriorityEnum.SPEED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await aiService.callAi(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
speechResult = SpeechTeamsResponse.model_validate_json(response.content)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
jsonStr = response.content
|
||||||
|
if "```json" in jsonStr:
|
||||||
|
jsonStr = jsonStr.split("```json")[1].split("```")[0]
|
||||||
|
elif "```" in jsonStr:
|
||||||
|
jsonStr = jsonStr.split("```")[1].split("```")[0]
|
||||||
|
speechResult = SpeechTeamsResponse.model_validate_json(jsonStr.strip())
|
||||||
|
except Exception as parseErr:
|
||||||
|
logger.warning(f"Failed to parse SPEECH_TEAMS response: {parseErr}")
|
||||||
|
speechResult = SpeechTeamsResponse(
|
||||||
|
shouldRespond=False,
|
||||||
|
reasoning=f"Parse error: {str(parseErr)[:100]}",
|
||||||
|
detectedIntent="none"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SPEECH_TEAMS result: shouldRespond={speechResult.shouldRespond}, "
|
||||||
|
f"intent={speechResult.detectedIntent}, "
|
||||||
|
f"reasoning={speechResult.reasoning[:80]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "analysis", {
|
||||||
|
"shouldRespond": speechResult.shouldRespond,
|
||||||
|
"detectedIntent": speechResult.detectedIntent,
|
||||||
|
"reasoning": speechResult.reasoning,
|
||||||
|
"modelName": response.modelName,
|
||||||
|
"processingTime": response.processingTime,
|
||||||
|
"priceCHF": response.priceCHF,
|
||||||
|
"needsAgent": speechResult.needsAgent,
|
||||||
|
"agentReason": speechResult.agentReason,
|
||||||
|
})
|
||||||
|
|
||||||
|
if speechResult.needsAgent:
|
||||||
|
briefings = service._collectActiveDirectorBriefings()
|
||||||
|
briefingFileIds = service._collectDirectorFileIds()
|
||||||
|
briefingBlock = ""
|
||||||
|
if briefings:
|
||||||
|
parts = []
|
||||||
|
for b in briefings:
|
||||||
|
seg = f"- ({b.get('mode')}) {b.get('text', '')}".rstrip()
|
||||||
|
if b.get("fileIds"):
|
||||||
|
seg += f"\n attachedFileIds: {', '.join(b['fileIds'])}"
|
||||||
|
if b.get("note"):
|
||||||
|
note = b["note"]
|
||||||
|
seg += (
|
||||||
|
"\n priorAgentAnalysis: "
|
||||||
|
+ (note if len(note) <= 800 else note[:800] + "...")
|
||||||
|
)
|
||||||
|
parts.append(seg)
|
||||||
|
briefingBlock = (
|
||||||
|
"\n\nACTIVE_OPERATOR_BRIEFINGS (private; you may read the "
|
||||||
|
"attached files via summarizeContent / readFile / "
|
||||||
|
"readContentObjects to answer the user precisely; do NOT "
|
||||||
|
"quote the directive text itself):\n" + "\n".join(parts)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: SPEECH_TEAMS escalates to agent. "
|
||||||
|
f"Reason: {speechResult.agentReason or speechResult.reasoning} | "
|
||||||
|
f"briefings={len(briefings)}, fileIds={len(briefingFileIds)}"
|
||||||
|
)
|
||||||
|
taskBrief = (
|
||||||
|
(speechResult.agentReason
|
||||||
|
or speechResult.responseText
|
||||||
|
or "Verarbeite die juengste Sprecheranfrage und antworte ins Meeting.")
|
||||||
|
+ briefingBlock
|
||||||
|
)
|
||||||
|
service._agentEscalationInFlight = True
|
||||||
|
service._currentEscalationTask = asyncio.create_task(
|
||||||
|
_runEscalationAndRelease(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
taskBrief=taskBrief,
|
||||||
|
briefingFileIds=briefingFileIds,
|
||||||
|
triggerTranscriptId=triggerTranscript.get("id"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if speechResult.detectedIntent == "stop":
|
||||||
|
logger.info(f"Session {sessionId}: AI detected STOP intent: {speechResult.reasoning}")
|
||||||
|
if websocket:
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "stopAudio",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
}))
|
||||||
|
except Exception as stopErr:
|
||||||
|
logger.warning(f"Failed to send stop command: {stopErr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if speechResult.shouldRespond and speechResult.responseText:
|
||||||
|
|
||||||
|
if service.config.responseMode == TeamsbotResponseMode.MANUAL:
|
||||||
|
await _emitSessionEvent(sessionId, "suggestedResponse", {
|
||||||
|
"responseText": speechResult.responseText,
|
||||||
|
"detectedIntent": speechResult.detectedIntent,
|
||||||
|
"reasoning": speechResult.reasoning,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
channels = speechResult.responseChannels
|
||||||
|
if channels and isinstance(channels, list):
|
||||||
|
channelStr = ",".join(str(c).lower().strip() for c in channels)
|
||||||
|
sendVoice = "voice" in channelStr
|
||||||
|
sendChat = "chat" in channelStr
|
||||||
|
logger.info(f"Response channel (from AI): voice={sendVoice}, chat={sendChat}")
|
||||||
|
else:
|
||||||
|
channelRaw = service.config.responseChannel
|
||||||
|
channelStr = (channelRaw.value if hasattr(channelRaw, 'value') else str(channelRaw)).lower().strip()
|
||||||
|
sendVoice = channelStr in ("voice", "both")
|
||||||
|
sendChat = channelStr in ("chat", "both")
|
||||||
|
logger.info(f"Response channel (from config): '{channelStr}'")
|
||||||
|
|
||||||
|
if sendVoice and sendChat:
|
||||||
|
responseType = TeamsbotResponseType.BOTH
|
||||||
|
elif sendVoice:
|
||||||
|
responseType = TeamsbotResponseType.AUDIO
|
||||||
|
else:
|
||||||
|
responseType = TeamsbotResponseType.CHAT
|
||||||
|
|
||||||
|
canonicalText = (
|
||||||
|
speechResult.responseText
|
||||||
|
or speechResult.responseTextForVoice
|
||||||
|
or speechResult.responseTextForChat
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
normalizedResponse = (canonicalText or "").strip().lower()
|
||||||
|
nowTs = time.time()
|
||||||
|
if (
|
||||||
|
normalizedResponse
|
||||||
|
and service._lastBotResponseText == normalizedResponse
|
||||||
|
and (nowTs - service._lastBotResponseTs) < 90
|
||||||
|
):
|
||||||
|
logger.info(f"Session {sessionId}: Suppressing duplicate bot response within 90s window")
|
||||||
|
await _emitSessionEvent(sessionId, "analysis", {
|
||||||
|
"shouldRespond": False,
|
||||||
|
"detectedIntent": speechResult.detectedIntent,
|
||||||
|
"reasoning": "Suppressed duplicate response within 90s",
|
||||||
|
"modelName": response.modelName,
|
||||||
|
"processingTime": response.processingTime,
|
||||||
|
"priceCHF": response.priceCHF,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
textForVoice = speechResult.responseTextForVoice or speechResult.responseText
|
||||||
|
textForChat = speechResult.responseTextForChat or speechResult.responseText
|
||||||
|
storedText = textForChat or textForVoice or speechResult.responseText
|
||||||
|
|
||||||
|
if sendVoice and textForVoice:
|
||||||
|
await _emitSessionEvent(sessionId, "ttsDeliveryStatus", {
|
||||||
|
"status": "requested",
|
||||||
|
"hasWebSocket": websocket is not None,
|
||||||
|
"message": "TTS generation requested",
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: TTS requested (websocket_available={websocket is not None})"
|
||||||
|
)
|
||||||
|
if not websocket:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: TTS skipped (bot websocket unavailable, likely fallback mode)"
|
||||||
|
)
|
||||||
|
await _emitSessionEvent(sessionId, "ttsDeliveryStatus", {
|
||||||
|
"status": "unavailable",
|
||||||
|
"hasWebSocket": False,
|
||||||
|
"message": "TTS skipped — bot websocket unavailable",
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
if not sendChat:
|
||||||
|
sendChat = True
|
||||||
|
else:
|
||||||
|
spokenText = await _summarizeForVoice(service, sessionId, textForVoice)
|
||||||
|
cancelHook = service._makeAnswerCancelHook()
|
||||||
|
async with service._meetingTtsLock:
|
||||||
|
ttsOutcome = await _speakTextChunked(
|
||||||
|
websocket=websocket,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
sessionId=sessionId,
|
||||||
|
voiceText=spokenText,
|
||||||
|
languageCode=service.config.language,
|
||||||
|
voiceName=service.config.voiceId,
|
||||||
|
isCancelled=cancelHook,
|
||||||
|
)
|
||||||
|
if ttsOutcome.get("success"):
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: TTS audio dispatched to bot "
|
||||||
|
f"(chunks={ttsOutcome.get('chunks')}, played={ttsOutcome.get('played')})"
|
||||||
|
)
|
||||||
|
await _emitSessionEvent(sessionId, "ttsDeliveryStatus", {
|
||||||
|
"status": "dispatched",
|
||||||
|
"hasWebSocket": True,
|
||||||
|
"chunks": ttsOutcome.get("chunks"),
|
||||||
|
"played": ttsOutcome.get("played"),
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"TTS failed for session {sessionId}: {ttsOutcome.get('error')}"
|
||||||
|
)
|
||||||
|
await _emitSessionEvent(sessionId, "ttsDeliveryStatus", {
|
||||||
|
"status": "failed",
|
||||||
|
"hasWebSocket": True,
|
||||||
|
"chunks": ttsOutcome.get("chunks"),
|
||||||
|
"played": ttsOutcome.get("played"),
|
||||||
|
"message": ttsOutcome.get("error"),
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
if not sendChat:
|
||||||
|
sendChat = True
|
||||||
|
|
||||||
|
if sendChat and textForChat:
|
||||||
|
try:
|
||||||
|
if websocket:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "sendChatMessage",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"text": textForChat,
|
||||||
|
}))
|
||||||
|
logger.info(f"Chat response sent for session {sessionId}")
|
||||||
|
except Exception as chatErr:
|
||||||
|
logger.warning(f"Chat message send failed for session {sessionId}: {chatErr}")
|
||||||
|
|
||||||
|
botResponseData = TeamsbotBotResponse(
|
||||||
|
sessionId=sessionId,
|
||||||
|
responseText=storedText,
|
||||||
|
responseType=responseType,
|
||||||
|
detectedIntent=speechResult.detectedIntent,
|
||||||
|
reasoning=speechResult.reasoning,
|
||||||
|
triggeredByTranscriptId=triggerTranscript.get("id"),
|
||||||
|
modelName=response.modelName,
|
||||||
|
processingTime=response.processingTime,
|
||||||
|
priceCHF=response.priceCHF,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
createdResponse = interface.createBotResponse(botResponseData)
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "botResponse", {
|
||||||
|
"id": createdResponse.get("id"),
|
||||||
|
"responseText": storedText,
|
||||||
|
"responseType": responseType.value,
|
||||||
|
"detectedIntent": speechResult.detectedIntent,
|
||||||
|
"reasoning": speechResult.reasoning,
|
||||||
|
"modelName": response.modelName,
|
||||||
|
"processingTime": response.processingTime,
|
||||||
|
"priceCHF": response.priceCHF,
|
||||||
|
"timestamp": botResponseData.get("timestamp"),
|
||||||
|
})
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
if session:
|
||||||
|
count = session.get("botResponseCount", 0) + 1
|
||||||
|
interface.updateSession(sessionId, {"botResponseCount": count})
|
||||||
|
|
||||||
|
service._lastBotResponseText = normalizedResponse
|
||||||
|
service._lastBotResponseTs = nowTs
|
||||||
|
|
||||||
|
botTranscriptData = TeamsbotTranscript(
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=service.config.botName,
|
||||||
|
text=storedText,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
confidence=1.0,
|
||||||
|
language=service.config.language,
|
||||||
|
isFinal=True,
|
||||||
|
).model_dump()
|
||||||
|
botTranscript = interface.createTranscript(botTranscriptData)
|
||||||
|
|
||||||
|
service._contextBuffer.append({
|
||||||
|
"speaker": service.config.botName,
|
||||||
|
"text": storedText,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"source": "botResponse",
|
||||||
|
})
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "transcript", {
|
||||||
|
"id": botTranscript.get("id"),
|
||||||
|
"speaker": service.config.botName,
|
||||||
|
"text": storedText,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"isContinuation": False,
|
||||||
|
"source": "botResponse",
|
||||||
|
"speakerResolvedFromHint": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
service._lastTranscriptSpeaker = service.config.botName
|
||||||
|
service._lastTranscriptText = storedText
|
||||||
|
service._lastTranscriptId = botTranscript.get("id")
|
||||||
|
|
||||||
|
service._followUpWindowEnd = time.time() + 15.0
|
||||||
|
logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}, follow-up window open for 15s")
|
||||||
|
|
||||||
|
if speechResult.commands:
|
||||||
|
from .serviceCommands import _executeCommands
|
||||||
|
await _executeCommands(service, sessionId, speechResult.commands, voiceInterface, websocket)
|
||||||
|
|
||||||
|
if speechResult.shouldRespond and not speechResult.responseText:
|
||||||
|
cmdTexts = [
|
||||||
|
c.params.get("text", "") for c in speechResult.commands
|
||||||
|
if c.action == "sendChat" and c.params and c.params.get("text")
|
||||||
|
]
|
||||||
|
combinedText = " ".join(cmdTexts) if cmdTexts else None
|
||||||
|
if combinedText:
|
||||||
|
botResponseData = TeamsbotBotResponse(
|
||||||
|
sessionId=sessionId,
|
||||||
|
responseText=combinedText,
|
||||||
|
responseType=TeamsbotResponseType.CHAT,
|
||||||
|
detectedIntent=speechResult.detectedIntent,
|
||||||
|
reasoning=speechResult.reasoning,
|
||||||
|
triggeredByTranscriptId=triggerTranscript.get("id"),
|
||||||
|
modelName=response.modelName,
|
||||||
|
processingTime=response.processingTime,
|
||||||
|
priceCHF=response.priceCHF,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
).model_dump()
|
||||||
|
createdResponse = interface.createBotResponse(botResponseData)
|
||||||
|
await _emitSessionEvent(sessionId, "botResponse", {
|
||||||
|
"id": createdResponse.get("id"),
|
||||||
|
"responseText": combinedText,
|
||||||
|
"responseType": TeamsbotResponseType.CHAT.value,
|
||||||
|
"detectedIntent": speechResult.detectedIntent,
|
||||||
|
"reasoning": speechResult.reasoning,
|
||||||
|
"modelName": response.modelName,
|
||||||
|
"processingTime": response.processingTime,
|
||||||
|
"priceCHF": response.priceCHF,
|
||||||
|
"timestamp": botResponseData.get("timestamp"),
|
||||||
|
})
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
if session:
|
||||||
|
count = session.get("botResponseCount", 0) + 1
|
||||||
|
interface.updateSession(sessionId, {"botResponseCount": count})
|
||||||
|
|
||||||
|
service._followUpWindowEnd = time.time() + 15.0
|
||||||
|
logger.info(
|
||||||
|
f"Bot responded via commands in session {sessionId}: "
|
||||||
|
f"intent={speechResult.detectedIntent}, follow-up window open for 15s"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {type(e).__name__}: {e}", exc_info=True)
|
||||||
|
await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {type(e).__name__}: {str(e)}"})
|
||||||
|
finally:
|
||||||
|
service._aiAnalysisInProgress = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _processTranscript(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
speaker: str,
|
||||||
|
text: str,
|
||||||
|
isFinal: bool,
|
||||||
|
interface,
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
source: str = "caption",
|
||||||
|
speakerResolvedFromHint: Optional[bool] = None,
|
||||||
|
):
|
||||||
|
"""Process a transcript segment from captions or chat messages."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
if source in ("caption", "speakerHint"):
|
||||||
|
service._registerSpeakerHint(speaker, text, sessionId)
|
||||||
|
|
||||||
|
if (
|
||||||
|
source == "speakerHint"
|
||||||
|
and isFinal
|
||||||
|
and not service._isBotSpeaker(speaker)
|
||||||
|
and service.config.responseMode != TeamsbotResponseMode.TRANSCRIBE_ONLY
|
||||||
|
and service._detectBotName(text)
|
||||||
|
):
|
||||||
|
triggerTranscript = {"id": None, "speaker": speaker, "text": text, "source": source}
|
||||||
|
isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, triggerTranscript)
|
||||||
|
if isNew:
|
||||||
|
logger.info(f"Session {sessionId}: Bot name in caption, debounce trigger started")
|
||||||
|
asyncio.create_task(_checkPendingNameTrigger(service))
|
||||||
|
service._currentQuickAckTask = asyncio.create_task(
|
||||||
|
_runQuickAck(service, sessionId)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if source == "chatHistory":
|
||||||
|
transcriptData = TeamsbotTranscript(
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=speaker,
|
||||||
|
text=text,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
confidence=1.0,
|
||||||
|
language=service.config.language,
|
||||||
|
isFinal=True,
|
||||||
|
source="chatHistory",
|
||||||
|
).model_dump()
|
||||||
|
createdTranscript = interface.createTranscript(transcriptData)
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "transcript", {
|
||||||
|
"id": createdTranscript.get("id"),
|
||||||
|
"speaker": speaker,
|
||||||
|
"text": text,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"isContinuation": False,
|
||||||
|
"source": "chatHistory",
|
||||||
|
"isHistory": True,
|
||||||
|
})
|
||||||
|
logger.debug(f"Session {sessionId}: Chat history stored (no AI trigger): [{speaker}] {text[:60]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
isBotSpeaker = service._isBotSpeaker(speaker)
|
||||||
|
if isBotSpeaker and source != "chat":
|
||||||
|
logger.debug(f"Session {sessionId}: Ignoring own bot caption from: [{speaker}] {text[:80]}...")
|
||||||
|
return
|
||||||
|
|
||||||
|
sttPauseThreshold = 5.0
|
||||||
|
isMerge = (
|
||||||
|
source == "audioCapture"
|
||||||
|
and service._lastTranscriptSpeaker == speaker
|
||||||
|
and service._lastTranscriptText is not None
|
||||||
|
and service._lastTranscriptId is not None
|
||||||
|
and (time.time() - service._lastSttTime) < sttPauseThreshold
|
||||||
|
)
|
||||||
|
|
||||||
|
if isMerge:
|
||||||
|
mergedText = f"{service._lastTranscriptText} {text}"
|
||||||
|
interface.updateTranscript(service._lastTranscriptId, {
|
||||||
|
"text": mergedText,
|
||||||
|
"isFinal": isFinal,
|
||||||
|
})
|
||||||
|
service._lastTranscriptText = mergedText
|
||||||
|
createdTranscript = {"id": service._lastTranscriptId}
|
||||||
|
|
||||||
|
if service._contextBuffer and service._contextBuffer[-1].get("speaker") == speaker:
|
||||||
|
service._contextBuffer[-1]["text"] = mergedText
|
||||||
|
else:
|
||||||
|
transcriptData = TeamsbotTranscript(
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=speaker,
|
||||||
|
text=text,
|
||||||
|
timestamp=getUtcTimestamp(),
|
||||||
|
confidence=1.0,
|
||||||
|
language=service.config.language,
|
||||||
|
isFinal=isFinal,
|
||||||
|
source=source,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
createdTranscript = interface.createTranscript(transcriptData)
|
||||||
|
|
||||||
|
service._lastTranscriptSpeaker = speaker
|
||||||
|
service._lastTranscriptText = text
|
||||||
|
service._lastTranscriptId = createdTranscript.get("id")
|
||||||
|
|
||||||
|
if source == "audioCapture" and speaker == "Unknown":
|
||||||
|
service._unattributedTranscriptIds.append(createdTranscript.get("id"))
|
||||||
|
|
||||||
|
service._contextBuffer.append({
|
||||||
|
"speaker": speaker or "Unknown",
|
||||||
|
"text": text,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"source": source,
|
||||||
|
})
|
||||||
|
|
||||||
|
maxSegments = service.config.contextWindowSegments
|
||||||
|
if len(service._contextBuffer) > maxSegments:
|
||||||
|
if not service._contextSummary and len(service._contextBuffer) > maxSegments * 1.5:
|
||||||
|
asyncio.create_task(service._summarizeContextBuffer(sessionId))
|
||||||
|
service._contextBuffer = service._contextBuffer[-maxSegments:]
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
if session:
|
||||||
|
count = session.get("transcriptSegmentCount", 0) + 1
|
||||||
|
interface.updateSession(sessionId, {"transcriptSegmentCount": count})
|
||||||
|
|
||||||
|
if source == "audioCapture":
|
||||||
|
service._lastSttTime = time.time()
|
||||||
|
|
||||||
|
displayText = service._lastTranscriptText if isMerge else text
|
||||||
|
await _emitSessionEvent(sessionId, "transcript", {
|
||||||
|
"id": createdTranscript.get("id"),
|
||||||
|
"speaker": speaker,
|
||||||
|
"text": displayText,
|
||||||
|
"confidence": 1.0,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
"isContinuation": isMerge,
|
||||||
|
"source": source,
|
||||||
|
"speakerResolvedFromHint": (
|
||||||
|
speakerResolvedFromHint
|
||||||
|
if speakerResolvedFromHint is not None
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not isFinal:
|
||||||
|
return
|
||||||
|
|
||||||
|
if service.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY:
|
||||||
|
return
|
||||||
|
|
||||||
|
if source == "chat" and isBotSpeaker:
|
||||||
|
return
|
||||||
|
|
||||||
|
if service._isStopPhrase(text):
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Stop phrase detected ('{text.strip()[:60]}'), "
|
||||||
|
f"hard-cancelling in-flight speech immediately"
|
||||||
|
)
|
||||||
|
from .serviceWebSocket import _cancelInFlightSpeech
|
||||||
|
await _cancelInFlightSpeech(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
websocket=websocket,
|
||||||
|
reason="userStopPhrase",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if service._pendingNameTrigger:
|
||||||
|
service._pendingNameTrigger["lastActivity"] = time.time()
|
||||||
|
|
||||||
|
if service._detectBotName(text):
|
||||||
|
isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript)
|
||||||
|
if isNew:
|
||||||
|
asyncio.create_task(_checkPendingNameTrigger(service))
|
||||||
|
service._currentQuickAckTask = asyncio.create_task(
|
||||||
|
_runQuickAck(service, sessionId)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
source == "audioCapture"
|
||||||
|
and not service._isBotSpeaker(speaker)
|
||||||
|
and time.time() < service._followUpWindowEnd
|
||||||
|
and not service._pendingNameTrigger
|
||||||
|
):
|
||||||
|
isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript)
|
||||||
|
if isNew:
|
||||||
|
logger.info(f"Session {sessionId}: Follow-up window trigger (no name needed)")
|
||||||
|
asyncio.create_task(_checkPendingNameTrigger(service))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not service._pendingNameTrigger:
|
||||||
|
shouldTrigger = service._shouldTriggerAnalysis(text)
|
||||||
|
if shouldTrigger:
|
||||||
|
logger.info(f"Session {sessionId}: Periodic trigger (buffer: {len(service._contextBuffer)} segments)")
|
||||||
|
await _analyzeAndRespond(service, sessionId, interface, voiceInterface, websocket, createdTranscript)
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarizeForVoice(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
rawAnswer: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return a SHORT, naturally-spoken paraphrase of ``rawAnswer`` for TTS."""
|
||||||
|
from .service import _voiceFriendlyMeetingText, createAiService
|
||||||
|
|
||||||
|
if not rawAnswer or not rawAnswer.strip():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
sanitised = _voiceFriendlyMeetingText(rawAnswer)
|
||||||
|
if (
|
||||||
|
len(sanitised) <= service._VOICE_DIRECT_MAX_CHARS
|
||||||
|
and not service._looksLikeStructuredText(rawAnswer)
|
||||||
|
):
|
||||||
|
return sanitised
|
||||||
|
|
||||||
|
targetLang = (service.config.language or "de-DE").strip()
|
||||||
|
botName = (service.config.botName or "").strip() or "the assistant"
|
||||||
|
persona = (service.config.aiSystemPrompt or "").strip()
|
||||||
|
personaBlock = (
|
||||||
|
f"\n\nBOT PERSONA / TONE:\n{persona}\n"
|
||||||
|
if persona else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"You are condensing a long written answer into a SHORT spoken "
|
||||||
|
f"paraphrase that the assistant '{botName}' will say out loud "
|
||||||
|
f"into a Microsoft Teams meeting. The full written answer is "
|
||||||
|
f"already in the meeting chat — your job is to summarise it for "
|
||||||
|
f"the EAR, not the eye.\n\n"
|
||||||
|
f"STRICT REQUIREMENTS:\n"
|
||||||
|
f"1. Output language: BCP-47 '{targetLang}'. No other language.\n"
|
||||||
|
f"2. 1 to 3 sentences, max ~{service._VOICE_SUMMARY_MAX_CHARS} characters total.\n"
|
||||||
|
f"3. Natural spoken style — no headings, no bullet points, no "
|
||||||
|
f"tables, no markdown, no emojis, no enumerations like 'Erstens... "
|
||||||
|
f"Zweitens...' unless that genuinely flows in speech.\n"
|
||||||
|
f"4. Capture the essence and the most important conclusion. Do "
|
||||||
|
f"NOT try to fit every detail. Listeners can read the chat for "
|
||||||
|
f"the full version.\n"
|
||||||
|
f"5. End by gently pointing the audience to the chat for details, "
|
||||||
|
f"e.g. 'Details stehen im Chat.' (adapted to the target language).\n"
|
||||||
|
f"6. Output ONLY the spoken text. No JSON, no quotes around it, "
|
||||||
|
f"no preamble like 'Here is the summary:'.\n"
|
||||||
|
f"{personaBlock}\n"
|
||||||
|
f"FULL WRITTEN ANSWER (markdown-formatted, sometimes long):\n"
|
||||||
|
f"---\n{rawAnswer.strip()[:6000]}\n---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
aiService = createAiService(
|
||||||
|
service.currentUser, service.mandateId, service.instanceId
|
||||||
|
)
|
||||||
|
await aiService.ensureAiObjectsInitialized()
|
||||||
|
request = AiCallRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
context="",
|
||||||
|
options=AiCallOptions(
|
||||||
|
operationType=OperationTypeEnum.DATA_ANALYSE,
|
||||||
|
priority=PriorityEnum.SPEED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await aiService.callAi(request)
|
||||||
|
except Exception as aiErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Voice summary AI call failed: {aiErr}"
|
||||||
|
)
|
||||||
|
return sanitised[: service._VOICE_DIRECT_MAX_CHARS]
|
||||||
|
|
||||||
|
if not response or response.errorCount != 0 or not response.content:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Voice summary returned empty/error"
|
||||||
|
)
|
||||||
|
return sanitised[: service._VOICE_DIRECT_MAX_CHARS]
|
||||||
|
|
||||||
|
spoken = response.content.strip()
|
||||||
|
spoken = _voiceFriendlyMeetingText(spoken)
|
||||||
|
if not spoken:
|
||||||
|
return sanitised[: service._VOICE_DIRECT_MAX_CHARS]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Voice summary generated "
|
||||||
|
f"(orig={len(rawAnswer)} chars, sanitised={len(sanitised)}, "
|
||||||
|
f"spoken={len(spoken)})"
|
||||||
|
)
|
||||||
|
return spoken
|
||||||
|
|
||||||
|
|
||||||
|
async def _pickQuickAckText(service) -> Optional[str]:
|
||||||
|
"""Return a short ack text in the bot's configured language."""
|
||||||
|
return await _pickEphemeralPhrase(service, "quickAck")
|
||||||
|
|
||||||
|
|
||||||
|
async def _pickEphemeralPhrase(
|
||||||
|
service,
|
||||||
|
kind: str,
|
||||||
|
substitutions: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Round-robin selector over the cached phrase pool for ``kind``."""
|
||||||
|
variants = await _getEphemeralPhrases(service, kind)
|
||||||
|
if not variants:
|
||||||
|
return None
|
||||||
|
idx = service._phrasePoolIdx.get(kind, 0) % len(variants)
|
||||||
|
service._phrasePoolIdx[kind] = (idx + 1) % len(variants)
|
||||||
|
chosen = variants[idx]
|
||||||
|
if substitutions:
|
||||||
|
try:
|
||||||
|
chosen = chosen.format(**substitutions)
|
||||||
|
except (KeyError, IndexError, ValueError) as fmtErr:
|
||||||
|
logger.debug(
|
||||||
|
f"Ephemeral phrase substitution failed for kind={kind}: {fmtErr}"
|
||||||
|
)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
async def _getEphemeralPhrases(service, kind: str) -> List[str]:
|
||||||
|
"""Return the cached pool of AI-generated variants for ``kind``."""
|
||||||
|
cached = service._phrasePool.get(kind)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
async with service._phrasePoolLock:
|
||||||
|
cached = service._phrasePool.get(kind)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
phrases = await _generateEphemeralPhrases(service, kind, 4)
|
||||||
|
if phrases:
|
||||||
|
service._phrasePool[kind] = phrases
|
||||||
|
return phrases
|
||||||
|
|
||||||
|
|
||||||
|
async def _generateEphemeralPhrases(
|
||||||
|
service, kind: str, count: int
|
||||||
|
) -> List[str]:
|
||||||
|
"""Ask the AI to produce ``count`` short utterances for ``kind``."""
|
||||||
|
from .service import createAiService, _EPHEMERAL_PHRASE_INTENTS
|
||||||
|
|
||||||
|
intent = _EPHEMERAL_PHRASE_INTENTS.get(kind)
|
||||||
|
if not intent:
|
||||||
|
logger.warning(f"Unknown ephemeral phrase kind requested: {kind}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
targetLang = (service.config.language or "").strip() or "en-US"
|
||||||
|
botName = (service.config.botName or "the assistant").strip()
|
||||||
|
persona = (service.config.aiSystemPrompt or "").strip()
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"You are localizing short SPOKEN-LANGUAGE utterances for a "
|
||||||
|
f"meeting assistant named '{botName}'.\n\n"
|
||||||
|
f"Persona / style guide for the assistant:\n"
|
||||||
|
f"{persona or '(no persona configured — use a neutral, polite, professional tone)'}\n\n"
|
||||||
|
f"Target spoken language (BCP-47 code): {targetLang}\n\n"
|
||||||
|
f"Utterance intent:\n{intent}\n\n"
|
||||||
|
f"Generate {count} DIFFERENT variants matching this intent, in "
|
||||||
|
f"the target language. Variants should feel natural when spoken "
|
||||||
|
f"aloud, not robotic. Do NOT include the assistant's name in "
|
||||||
|
f"the variants.\n\n"
|
||||||
|
f"Output STRICTLY a JSON array of {count} plain-text strings, "
|
||||||
|
f"with no markdown fences, no commentary, no surrounding "
|
||||||
|
f"quotation marks beyond the JSON syntax itself. Example "
|
||||||
|
f"format: [\"...\", \"...\", \"...\", \"...\"]"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
aiService = createAiService(
|
||||||
|
service.currentUser, service.mandateId, service.instanceId
|
||||||
|
)
|
||||||
|
await aiService.ensureAiObjectsInitialized()
|
||||||
|
request = AiCallRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
context="",
|
||||||
|
options=AiCallOptions(
|
||||||
|
operationType=OperationTypeEnum.DATA_ANALYSE,
|
||||||
|
priority=PriorityEnum.SPEED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await aiService.callAi(request)
|
||||||
|
except Exception as aiErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Ephemeral phrase generation failed (kind={kind}, lang={targetLang}): {aiErr}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not response or response.errorCount != 0 or not response.content:
|
||||||
|
logger.warning(
|
||||||
|
f"Ephemeral phrase generation returned empty/error "
|
||||||
|
f"(kind={kind}, lang={targetLang})"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
raw = response.content.strip()
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
|
raw = re.sub(r"\s*```\s*$", "", raw)
|
||||||
|
try:
|
||||||
|
arr = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as parseErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Ephemeral phrase generation: could not parse JSON "
|
||||||
|
f"(kind={kind}, lang={targetLang}): {parseErr} "
|
||||||
|
f"raw={raw[:200]}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if not isinstance(arr, list):
|
||||||
|
return []
|
||||||
|
cleaned = [
|
||||||
|
str(v).strip()
|
||||||
|
for v in arr
|
||||||
|
if isinstance(v, str) and str(v).strip()
|
||||||
|
]
|
||||||
|
cleaned = cleaned[:count]
|
||||||
|
if cleaned:
|
||||||
|
logger.info(
|
||||||
|
f"Ephemeral phrase pool generated (kind={kind}, "
|
||||||
|
f"lang={targetLang}, count={len(cleaned)})"
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
async def _runQuickAck(service, sessionId: str) -> None:
|
||||||
|
"""Background task: speak a short ack into the meeting via TTS."""
|
||||||
|
from .service import _emitSessionEvent, _speakTextChunked
|
||||||
|
|
||||||
|
websocket = service._websocket
|
||||||
|
voiceInterface = service._voiceInterface
|
||||||
|
if websocket is None or voiceInterface is None:
|
||||||
|
return
|
||||||
|
if not service._shouldFireQuickAck():
|
||||||
|
return
|
||||||
|
ackText = await _pickQuickAckText(service)
|
||||||
|
if not ackText:
|
||||||
|
return
|
||||||
|
service._lastQuickAckTs = time.time()
|
||||||
|
try:
|
||||||
|
await _emitSessionEvent(sessionId, "quickAck", {
|
||||||
|
"text": ackText,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
cancelHook = service._makeAnswerCancelHook()
|
||||||
|
async with service._meetingTtsLock:
|
||||||
|
outcome = await _speakTextChunked(
|
||||||
|
websocket=websocket,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
sessionId=sessionId,
|
||||||
|
voiceText=ackText,
|
||||||
|
languageCode=service.config.language,
|
||||||
|
voiceName=service.config.voiceId,
|
||||||
|
isCancelled=cancelHook,
|
||||||
|
)
|
||||||
|
if not outcome.get("success"):
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Quick ack TTS failed silently "
|
||||||
|
f"({outcome.get('error')}) — main response will still go through"
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(f"Session {sessionId}: Quick ack cancelled by stop signal")
|
||||||
|
except Exception as ackErr:
|
||||||
|
logger.warning(f"Session {sessionId}: Quick ack failed: {ackErr}")
|
||||||
|
finally:
|
||||||
|
service._currentQuickAckTask = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _checkPendingNameTrigger(service, delaySec: float = 3.0):
|
||||||
|
"""Async loop: fire the pending name trigger once the speaker is quiet."""
|
||||||
|
await asyncio.sleep(delaySec)
|
||||||
|
if not service._pendingNameTrigger:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
lastActivity = service._pendingNameTrigger.get("lastActivity", 0)
|
||||||
|
detectedAt = service._pendingNameTrigger.get("detectedAt", 0)
|
||||||
|
quietSec = now - lastActivity
|
||||||
|
totalWaitSec = now - detectedAt
|
||||||
|
|
||||||
|
if quietSec >= 3.0 or totalWaitSec >= 15.0:
|
||||||
|
trigger = service._pendingNameTrigger
|
||||||
|
service._pendingNameTrigger = None
|
||||||
|
logger.info(
|
||||||
|
f"Session {trigger['sessionId']}: Debounced name trigger fires "
|
||||||
|
f"(quiet={quietSec:.1f}s, totalWait={totalWaitSec:.1f}s)"
|
||||||
|
)
|
||||||
|
await _analyzeAndRespond(
|
||||||
|
service,
|
||||||
|
trigger["sessionId"],
|
||||||
|
trigger["interface"],
|
||||||
|
trigger["voiceInterface"],
|
||||||
|
trigger["websocket"],
|
||||||
|
trigger["triggerTranscript"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remaining = max(0.5, 3.0 - quietSec)
|
||||||
|
asyncio.create_task(_checkPendingNameTrigger(service, remaining))
|
||||||
|
|
||||||
|
|
||||||
|
async def _warmEphemeralPhrasePool(service, sessionId: str) -> None:
|
||||||
|
"""Fire-and-forget: generate ephemeral phrase pool for all kinds."""
|
||||||
|
from .service import _EPHEMERAL_PHRASE_INTENTS
|
||||||
|
|
||||||
|
try:
|
||||||
|
for kind in _EPHEMERAL_PHRASE_INTENTS:
|
||||||
|
try:
|
||||||
|
await _getEphemeralPhrases(service, kind)
|
||||||
|
except Exception as innerErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Phrase pool warmup failed for "
|
||||||
|
f"kind={kind}: {innerErr}"
|
||||||
|
)
|
||||||
|
except Exception as warmErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Phrase pool warmup task crashed: {warmErr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _runEscalationAndRelease(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
taskBrief: str,
|
||||||
|
briefingFileIds: List[str],
|
||||||
|
triggerTranscriptId: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Background wrapper for ``_runAgentForMeeting`` that holds the
|
||||||
|
``_agentEscalationInFlight`` flag for the duration of the agent run."""
|
||||||
|
try:
|
||||||
|
await service._runAgentForMeeting(
|
||||||
|
sessionId=sessionId,
|
||||||
|
taskText=taskBrief,
|
||||||
|
fileIds=briefingFileIds,
|
||||||
|
sourceLabel="speechEscalation",
|
||||||
|
triggerTranscriptId=triggerTranscriptId,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Escalation agent task cancelled by stop signal"
|
||||||
|
)
|
||||||
|
except Exception as escErr:
|
||||||
|
logger.error(
|
||||||
|
f"Session {sessionId}: Escalation agent task failed: "
|
||||||
|
f"{type(escErr).__name__}: {escErr}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
service._agentEscalationInFlight = False
|
||||||
|
service._currentEscalationTask = None
|
||||||
545
modules/features/teamsbot/serviceWebSocket.py
Normal file
545
modules/features/teamsbot/serviceWebSocket.py
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Teamsbot Service — WebSocket handler & audio chunk processing.
|
||||||
|
|
||||||
|
Extracted from service.py. All functions accept `service` (a TeamsbotService
|
||||||
|
instance) as the first parameter so the class can delegate to them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def handleBotWebSocket(service, websocket: WebSocket, sessionId: str):
|
||||||
|
"""Main WebSocket handler for Browser Bot communication."""
|
||||||
|
from . import interfaceFeatureTeamsbot as interfaceDb
|
||||||
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
|
from .service import _activeServices, _emitSessionEvent, sessionEvents
|
||||||
|
from .serviceConversation import _processTranscript, _warmEphemeralPhrasePool
|
||||||
|
|
||||||
|
interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId)
|
||||||
|
voiceInterface = getVoiceInterface(service.currentUser, service.mandateId)
|
||||||
|
|
||||||
|
session = interface.getSession(sessionId)
|
||||||
|
if session:
|
||||||
|
rawContext = session.get("sessionContext")
|
||||||
|
if rawContext and len(rawContext) > 500:
|
||||||
|
logger.info(f"Session {sessionId}: Summarizing long session context ({len(rawContext)} chars)...")
|
||||||
|
service._sessionContext = await service._summarizeSessionContext(sessionId, rawContext)
|
||||||
|
elif rawContext:
|
||||||
|
service._sessionContext = rawContext
|
||||||
|
if service._sessionContext:
|
||||||
|
logger.info(f"Session {sessionId}: Session context ready ({len(service._sessionContext)} chars)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
systemBot = interface.getActiveSystemBot(service.mandateId)
|
||||||
|
service._botAccountEmail = systemBot.get("email") if systemBot else None
|
||||||
|
if service._botAccountEmail:
|
||||||
|
logger.info(f"Session {sessionId}: Bot account email resolved: {service._botAccountEmail}")
|
||||||
|
except Exception:
|
||||||
|
service._botAccountEmail = None
|
||||||
|
|
||||||
|
service._activeSessionId = sessionId
|
||||||
|
service._websocket = websocket
|
||||||
|
service._voiceInterface = voiceInterface
|
||||||
|
_activeServices[sessionId] = service
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _emitSessionEvent(sessionId, "botConnectionState", {
|
||||||
|
"connected": True,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
service._activePersistentPrompts = interface.getActivePersistentPrompts(sessionId) or []
|
||||||
|
if service._activePersistentPrompts:
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Loaded {len(service._activePersistentPrompts)} active persistent director prompt(s)"
|
||||||
|
)
|
||||||
|
except Exception as restoreErr:
|
||||||
|
logger.warning(f"Session {sessionId}: Could not restore persistent director prompts: {restoreErr}")
|
||||||
|
service._activePersistentPrompts = []
|
||||||
|
|
||||||
|
asyncio.create_task(_warmEphemeralPhrasePool(service, sessionId))
|
||||||
|
|
||||||
|
logger.info(f"[WS] Handler started for session {sessionId}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
msgCount = 0
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive()
|
||||||
|
msgCount += 1
|
||||||
|
|
||||||
|
if "text" not in data:
|
||||||
|
logger.debug(f"[WS] session={sessionId} msg #{msgCount}: non-text data (keys: {list(data.keys())})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = json.loads(data["text"])
|
||||||
|
msgType = message.get("type")
|
||||||
|
if msgType not in ("audioChunk", "ping"):
|
||||||
|
logger.info(f"[WS] session={sessionId} msg #{msgCount}: type={msgType}")
|
||||||
|
|
||||||
|
if msgType == "transcript":
|
||||||
|
transcript = message.get("transcript", {})
|
||||||
|
source = transcript.get("source", "caption")
|
||||||
|
speaker = transcript.get("speaker", "Unknown")
|
||||||
|
textPreview = (transcript.get("text", "") or "")[:60]
|
||||||
|
logger.info(f"[WS] Transcript (source={source}, speaker={speaker}): {textPreview}...")
|
||||||
|
await _processTranscript(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=transcript.get("speaker", "Unknown"),
|
||||||
|
text=transcript.get("text", ""),
|
||||||
|
isFinal=transcript.get("isFinal", True),
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "chatMessage":
|
||||||
|
chat = message.get("chat", {})
|
||||||
|
isHistory = chat.get("isHistory", False)
|
||||||
|
source = "chatHistory" if isHistory else "chat"
|
||||||
|
logger.info(
|
||||||
|
f"[WS] Chat{'[HISTORY]' if isHistory else ''}: "
|
||||||
|
f"speaker={chat.get('speaker')}, text={chat.get('text', '')[:60]}..."
|
||||||
|
)
|
||||||
|
await _processTranscript(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=chat.get("speaker", "Unknown"),
|
||||||
|
text=chat.get("text", ""),
|
||||||
|
isFinal=True,
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "status":
|
||||||
|
status = message.get("status")
|
||||||
|
errorMessage = message.get("message")
|
||||||
|
logger.info(f"[WS] Status: status={status}, message={errorMessage}")
|
||||||
|
await _handleBotStatus(service, sessionId, status, errorMessage, interface)
|
||||||
|
|
||||||
|
elif msgType == "audioChunk":
|
||||||
|
audioData = message.get("audio", {})
|
||||||
|
audioBase64 = audioData.get("data", "")
|
||||||
|
sampleRate = audioData.get("sampleRate", 16000)
|
||||||
|
captureDiagnostics = audioData.get("captureDiagnostics") or {}
|
||||||
|
if audioBase64:
|
||||||
|
await _processAudioChunk(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
audioBase64=audioBase64,
|
||||||
|
sampleRate=sampleRate,
|
||||||
|
captureDiagnostics=captureDiagnostics,
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "voiceGreeting":
|
||||||
|
greetingText = message.get("text", "")
|
||||||
|
greetingLang = message.get("language", service.config.language)
|
||||||
|
logger.info(
|
||||||
|
f"[WS] Voice greeting (legacy): text={greetingText[:60]}..., language={greetingLang}"
|
||||||
|
)
|
||||||
|
if greetingText and voiceInterface:
|
||||||
|
await service._dispatchGreetingToMeeting(
|
||||||
|
sessionId=sessionId,
|
||||||
|
greetingText=greetingText,
|
||||||
|
greetingLang=greetingLang,
|
||||||
|
sendToChat=False,
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "requestGreeting":
|
||||||
|
requestedLang = (
|
||||||
|
message.get("language") or service.config.language or ""
|
||||||
|
).strip() or "en-US"
|
||||||
|
botNameHint = (
|
||||||
|
message.get("botName") or service.config.botName or ""
|
||||||
|
).strip() or service.config.botName
|
||||||
|
logger.info(
|
||||||
|
f"[WS] Greeting request from bot: language={requestedLang}, name={botNameHint}"
|
||||||
|
)
|
||||||
|
if voiceInterface:
|
||||||
|
try:
|
||||||
|
greetingText = await service._generateGreetingText(
|
||||||
|
requestedLang
|
||||||
|
)
|
||||||
|
except Exception as genErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Greeting generation failed for session {sessionId}: {genErr}"
|
||||||
|
)
|
||||||
|
greetingText = ""
|
||||||
|
if greetingText:
|
||||||
|
await service._dispatchGreetingToMeeting(
|
||||||
|
sessionId=sessionId,
|
||||||
|
greetingText=greetingText,
|
||||||
|
greetingLang=requestedLang,
|
||||||
|
sendToChat=True,
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Skipping greeting — AI generation produced no text"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "ping":
|
||||||
|
await websocket.send_text(json.dumps({"type": "pong"}))
|
||||||
|
|
||||||
|
elif msgType == "ttsPlaybackAck":
|
||||||
|
playback = message.get("playback", {}) or {}
|
||||||
|
status = playback.get("status", "unknown")
|
||||||
|
ackMessage = playback.get("message") or "Bot playback status update"
|
||||||
|
logger.info(
|
||||||
|
f"[WS] TTS playback ack: status={status}, format={playback.get('format')}, "
|
||||||
|
f"bytesBase64={playback.get('bytesBase64')}"
|
||||||
|
)
|
||||||
|
await _emitSessionEvent(sessionId, "ttsDeliveryStatus", {
|
||||||
|
"status": f"playback_{status}",
|
||||||
|
"hasWebSocket": True,
|
||||||
|
"message": ackMessage,
|
||||||
|
"timestamp": playback.get("timestamp") or getUtcTimestamp(),
|
||||||
|
"format": playback.get("format"),
|
||||||
|
"bytesBase64": playback.get("bytesBase64"),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif msgType == "mfaChallenge":
|
||||||
|
mfaData = message.get("mfa", {})
|
||||||
|
mfaType = mfaData.get("type", "unknown")
|
||||||
|
displayNumber = mfaData.get("displayNumber")
|
||||||
|
prompt = mfaData.get("prompt", "")
|
||||||
|
logger.info(f"[WS] MFA challenge: type={mfaType}, number={displayNumber}, prompt={prompt[:60]}")
|
||||||
|
|
||||||
|
await _emitSessionEvent(sessionId, "mfaChallenge", {
|
||||||
|
"mfaType": mfaType,
|
||||||
|
"displayNumber": displayNumber,
|
||||||
|
"prompt": prompt,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
|
||||||
|
from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks
|
||||||
|
mfaQueue = asyncio.Queue()
|
||||||
|
mfaCodeQueues[sessionId] = mfaQueue
|
||||||
|
|
||||||
|
mfaWaitTasks[sessionId] = asyncio.create_task(
|
||||||
|
_waitAndForwardMfa(sessionId, mfaQueue, websocket)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif msgType == "chatSendFailed":
|
||||||
|
errorData = message.get("error", {})
|
||||||
|
reason = errorData.get("reason", "unknown")
|
||||||
|
failedText = errorData.get("text", "")
|
||||||
|
logger.warning(
|
||||||
|
f"[WS] Chat send failed for session {sessionId}: "
|
||||||
|
f"reason={reason}, text={failedText[:60]}"
|
||||||
|
)
|
||||||
|
await _emitSessionEvent(sessionId, "chatSendFailed", {
|
||||||
|
"reason": reason,
|
||||||
|
"message": errorData.get("message", "Chat message could not be sent"),
|
||||||
|
"text": failedText,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif msgType == "mfaResolved":
|
||||||
|
success = message.get("success", False)
|
||||||
|
logger.info(f"[WS] MFA resolved: success={success}")
|
||||||
|
from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks
|
||||||
|
task = mfaWaitTasks.pop(sessionId, None)
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
mfaCodeQueues.pop(sessionId, None)
|
||||||
|
await _emitSessionEvent(sessionId, "mfaResolved", {
|
||||||
|
"success": success,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "disconnect" not in str(e).lower():
|
||||||
|
logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}")
|
||||||
|
finally:
|
||||||
|
if _activeServices.get(sessionId) is service:
|
||||||
|
_activeServices.pop(sessionId, None)
|
||||||
|
service._websocket = None
|
||||||
|
service._voiceInterface = None
|
||||||
|
service._activeSessionId = None
|
||||||
|
try:
|
||||||
|
await _emitSessionEvent(sessionId, "botConnectionState", {
|
||||||
|
"connected": False,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"[WS] Handler ended for session {sessionId} after {msgCount} messages")
|
||||||
|
|
||||||
|
|
||||||
|
async def _waitAndForwardMfa(sid: str, queue: asyncio.Queue, ws: WebSocket):
|
||||||
|
"""Wait for an MFA code from the operator and forward it to the bot."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks
|
||||||
|
|
||||||
|
try:
|
||||||
|
mfaResponse = await asyncio.wait_for(queue.get(), timeout=120.0)
|
||||||
|
logger.info(f"[WS] MFA response received for session {sid}: action={mfaResponse.get('action')}")
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "mfaResponse",
|
||||||
|
"sessionId": sid,
|
||||||
|
"mfa": mfaResponse,
|
||||||
|
}))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"[WS] MFA response timeout for session {sid}")
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "mfaResponse",
|
||||||
|
"sessionId": sid,
|
||||||
|
"mfa": {"action": "timeout"},
|
||||||
|
}))
|
||||||
|
await _emitSessionEvent(sid, "mfaChallenge", {
|
||||||
|
"mfaType": "timeout",
|
||||||
|
"prompt": "MFA-Zeitlimit ueberschritten. Bitte erneut versuchen.",
|
||||||
|
})
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(f"[WS] MFA wait cancelled for session {sid} (resolved via page)")
|
||||||
|
finally:
|
||||||
|
mfaCodeQueues.pop(sid, None)
|
||||||
|
mfaWaitTasks.pop(sid, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handleBotStatus(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
status: str,
|
||||||
|
errorMessage: Optional[str],
|
||||||
|
interface,
|
||||||
|
):
|
||||||
|
"""Handle status updates from the browser bot."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
from .datamodelTeamsbot import TeamsbotSessionStatus
|
||||||
|
|
||||||
|
logger.info(f"Bot status update for session {sessionId}: {status}")
|
||||||
|
|
||||||
|
statusMap = {
|
||||||
|
"connecting": TeamsbotSessionStatus.JOINING.value,
|
||||||
|
"launching": TeamsbotSessionStatus.JOINING.value,
|
||||||
|
"navigating": TeamsbotSessionStatus.JOINING.value,
|
||||||
|
"in_lobby": TeamsbotSessionStatus.JOINING.value,
|
||||||
|
"joined": TeamsbotSessionStatus.ACTIVE.value,
|
||||||
|
"in_meeting": TeamsbotSessionStatus.ACTIVE.value,
|
||||||
|
"left": TeamsbotSessionStatus.ENDED.value,
|
||||||
|
"error": TeamsbotSessionStatus.ERROR.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
dbStatus = statusMap.get(status, TeamsbotSessionStatus.ACTIVE.value)
|
||||||
|
|
||||||
|
updates = {"status": dbStatus}
|
||||||
|
if errorMessage:
|
||||||
|
updates["errorMessage"] = errorMessage
|
||||||
|
if dbStatus == TeamsbotSessionStatus.ACTIVE.value:
|
||||||
|
updates["startedAt"] = getUtcTimestamp()
|
||||||
|
elif dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]:
|
||||||
|
updates["endedAt"] = getUtcTimestamp()
|
||||||
|
|
||||||
|
interface.updateSession(sessionId, updates)
|
||||||
|
await _emitSessionEvent(sessionId, "statusChange", {"status": status, "errorMessage": errorMessage})
|
||||||
|
|
||||||
|
if dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]:
|
||||||
|
if service._audioBuffer:
|
||||||
|
logger.info(f"[AudioChunk] Flushing remaining buffer on session end ({len(service._audioBuffer)} bytes)")
|
||||||
|
service._audioBuffer = b""
|
||||||
|
service._audioBufferStartTime = 0.0
|
||||||
|
service._audioBufferLastChunkTime = 0.0
|
||||||
|
|
||||||
|
if dbStatus == TeamsbotSessionStatus.ENDED.value:
|
||||||
|
asyncio.create_task(service._generateMeetingSummary(sessionId))
|
||||||
|
|
||||||
|
|
||||||
|
async def _processAudioChunk(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
audioBase64: str,
|
||||||
|
sampleRate: int,
|
||||||
|
captureDiagnostics: Optional[Dict[str, Any]],
|
||||||
|
interface,
|
||||||
|
voiceInterface,
|
||||||
|
websocket: WebSocket,
|
||||||
|
):
|
||||||
|
"""Process an audio chunk from WebRTC capture."""
|
||||||
|
from .serviceConversation import _processTranscript
|
||||||
|
|
||||||
|
_MIN_CHUNK_SEC = 1.0
|
||||||
|
_STALE_TIMEOUT_SEC = 3.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
audioBytes = base64.b64decode(audioBase64)
|
||||||
|
if len(audioBytes) < 500:
|
||||||
|
return
|
||||||
|
|
||||||
|
if captureDiagnostics:
|
||||||
|
trackId = captureDiagnostics.get("trackId")
|
||||||
|
readyState = captureDiagnostics.get("readyState")
|
||||||
|
rms = captureDiagnostics.get("rms")
|
||||||
|
nativeSampleRate = captureDiagnostics.get("nativeSampleRate")
|
||||||
|
logger.debug(
|
||||||
|
f"[AudioChunk] diagnostics: track={trackId}, readyState={readyState}, "
|
||||||
|
f"rms={rms}, nativeRate={nativeSampleRate}, bytes={len(audioBytes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
isSilent = False
|
||||||
|
if captureDiagnostics and captureDiagnostics.get("rms") is not None:
|
||||||
|
try:
|
||||||
|
rmsVal = float(captureDiagnostics.get("rms"))
|
||||||
|
if rmsVal < 0.0003:
|
||||||
|
isSilent = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not voiceInterface:
|
||||||
|
logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
effectiveRate = sampleRate if sampleRate and sampleRate > 0 else 16000
|
||||||
|
|
||||||
|
if not isSilent:
|
||||||
|
if not service._audioBuffer:
|
||||||
|
service._audioBufferStartTime = now
|
||||||
|
service._audioBuffer += audioBytes
|
||||||
|
service._audioBufferLastChunkTime = now
|
||||||
|
service._audioBufferSampleRate = effectiveRate
|
||||||
|
|
||||||
|
bufferDuration = len(service._audioBuffer) / (effectiveRate * 2) if service._audioBuffer else 0.0
|
||||||
|
bufferAge = (now - service._audioBufferStartTime) if service._audioBuffer else 0.0
|
||||||
|
|
||||||
|
shouldFlush = (
|
||||||
|
service._audioBuffer
|
||||||
|
and (
|
||||||
|
bufferDuration >= _MIN_CHUNK_SEC
|
||||||
|
or (bufferAge >= _STALE_TIMEOUT_SEC and bufferDuration > 0.3)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shouldFlush:
|
||||||
|
return
|
||||||
|
|
||||||
|
flushBytes = service._audioBuffer
|
||||||
|
flushRate = service._audioBufferSampleRate
|
||||||
|
service._audioBuffer = b""
|
||||||
|
service._audioBufferStartTime = 0.0
|
||||||
|
service._audioBufferLastChunkTime = 0.0
|
||||||
|
|
||||||
|
flushDuration = len(flushBytes) / (flushRate * 2)
|
||||||
|
logger.info(f"[AudioChunk] Flushing buffer: {len(flushBytes)} bytes, {flushDuration:.1f}s, {flushRate}Hz")
|
||||||
|
|
||||||
|
phraseHints = list(service._knownSpeakers)
|
||||||
|
if service.config.botName:
|
||||||
|
phraseHints.append(service.config.botName)
|
||||||
|
|
||||||
|
sttResult = await voiceInterface.speechToText(
|
||||||
|
audioContent=flushBytes,
|
||||||
|
language=service.config.language or "de-DE",
|
||||||
|
sampleRate=flushRate,
|
||||||
|
channels=1,
|
||||||
|
skipFallbacks=True,
|
||||||
|
phraseHints=phraseHints if phraseHints else None,
|
||||||
|
audioFormat="linear16",
|
||||||
|
)
|
||||||
|
|
||||||
|
if sttResult and sttResult.get("success") and sttResult.get("text"):
|
||||||
|
text = sttResult["text"].strip()
|
||||||
|
if text:
|
||||||
|
resolvedSpeaker = service._resolveSpeakerForAudioCapture()
|
||||||
|
fromCaption = resolvedSpeaker.get("speakerResolvedFromHint", False)
|
||||||
|
logger.info(
|
||||||
|
f"[AudioChunk] STT result: speaker={resolvedSpeaker.get('speaker', 'Meeting Audio')} "
|
||||||
|
f"(fromCaption={fromCaption}), text={text[:80]}..."
|
||||||
|
)
|
||||||
|
await _processTranscript(
|
||||||
|
service,
|
||||||
|
sessionId=sessionId,
|
||||||
|
speaker=resolvedSpeaker["speaker"],
|
||||||
|
text=text,
|
||||||
|
isFinal=True,
|
||||||
|
interface=interface,
|
||||||
|
voiceInterface=voiceInterface,
|
||||||
|
websocket=websocket,
|
||||||
|
source="audioCapture",
|
||||||
|
speakerResolvedFromHint=resolvedSpeaker["speakerResolvedFromHint"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[AudioChunk] STT error for session {sessionId}: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancelInFlightSpeech(
|
||||||
|
service,
|
||||||
|
sessionId: str,
|
||||||
|
websocket: Optional[WebSocket],
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
"""Hard stop everything the bot is currently doing in the meeting."""
|
||||||
|
from .service import _emitSessionEvent
|
||||||
|
|
||||||
|
service._answerGenerationCounter += 1
|
||||||
|
gen = service._answerGenerationCounter
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Cancelling in-flight speech "
|
||||||
|
f"(reason={reason}, gen={gen})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if service._pendingNameTrigger:
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Dropping pending debounced name "
|
||||||
|
f"trigger (was queued before stop)"
|
||||||
|
)
|
||||||
|
service._pendingNameTrigger = None
|
||||||
|
|
||||||
|
for taskAttr in ("_currentEscalationTask", "_currentQuickAckTask"):
|
||||||
|
task = getattr(service, taskAttr, None)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
logger.info(
|
||||||
|
f"Session {sessionId}: Cancelling background task "
|
||||||
|
f"{taskAttr}"
|
||||||
|
)
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
if websocket is not None:
|
||||||
|
try:
|
||||||
|
await websocket.send_text(json.dumps({
|
||||||
|
"type": "stopAudio",
|
||||||
|
"sessionId": sessionId,
|
||||||
|
"reason": reason,
|
||||||
|
}))
|
||||||
|
except Exception as stopErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Session {sessionId}: Failed to send stopAudio to "
|
||||||
|
f"browser bot: {stopErr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _emitSessionEvent(sessionId, "speechCancelled", {
|
||||||
|
"reason": reason,
|
||||||
|
"generation": gen,
|
||||||
|
"timestamp": getUtcTimestamp(),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
371
modules/features/trustee/handlerTrusteeAccounting.py
Normal file
371
modules/features/trustee/handlerTrusteeAccounting.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Business logic for Trustee accounting integration endpoints.
|
||||||
|
Extracted from routeFeatureTrustee.py for maintainability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CONFIG_PLACEHOLDER = "***"
|
||||||
|
|
||||||
|
|
||||||
|
class SaveAccountingConfigBody(BaseModel):
|
||||||
|
"""Request body for saving accounting config."""
|
||||||
|
connectorType: str = ""
|
||||||
|
displayLabel: str = ""
|
||||||
|
config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)")
|
||||||
|
|
||||||
|
|
||||||
|
def getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
"""Build config with secret values replaced by placeholder for GET response."""
|
||||||
|
from .accounting.accountingRegistry import getAccountingRegistry
|
||||||
|
connector = getAccountingRegistry().getConnector(connectorType)
|
||||||
|
if not connector:
|
||||||
|
return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()}
|
||||||
|
secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret}
|
||||||
|
return {
|
||||||
|
k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "")
|
||||||
|
for k, v in (plainConfig or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refreshChartSilently(interface, instanceId: str) -> None:
|
||||||
|
"""Best-effort chart-of-accounts cache refresh. Logs but does not raise on failure."""
|
||||||
|
try:
|
||||||
|
from .accounting.accountingBridge import AccountingBridge
|
||||||
|
bridge = AccountingBridge(interface)
|
||||||
|
charts = await bridge.refreshChartOfAccounts(instanceId)
|
||||||
|
logger.info(f"Chart cache refreshed: {len(charts)} entries for instance {instanceId}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Chart cache refresh failed (non-critical): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def readAccountingConfig(interface, instanceId: str) -> Dict[str, Any]:
|
||||||
|
"""Read and return the masked accounting config for an instance."""
|
||||||
|
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
|
from modules.shared.configuration import decryptValue
|
||||||
|
|
||||||
|
records = interface.db.getRecordset(
|
||||||
|
TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True}
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return {"configured": False}
|
||||||
|
|
||||||
|
record = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||||
|
encryptedConfig = record.pop("encryptedConfig", None)
|
||||||
|
record["configured"] = True
|
||||||
|
if encryptedConfig:
|
||||||
|
try:
|
||||||
|
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
|
||||||
|
record["configMasked"] = getConfigMasked(record.get("connectorType", ""), plain)
|
||||||
|
except Exception:
|
||||||
|
record["configMasked"] = {}
|
||||||
|
else:
|
||||||
|
record["configMasked"] = {}
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def saveAccountingConfig(interface, instanceId: str, mandateId: str, body: "SaveAccountingConfigBody") -> Dict[str, Any]:
|
||||||
|
"""Save or update accounting config with encrypted credentials and config merging."""
|
||||||
|
import uuid as _uuid
|
||||||
|
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
|
from modules.shared.configuration import encryptValue, decryptValue
|
||||||
|
|
||||||
|
plainConfig = body.config if isinstance(body.config, dict) else {}
|
||||||
|
if not plainConfig and body.connectorType:
|
||||||
|
logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Accounting config save: instanceId=%s connectorType=%s configKeys=%s",
|
||||||
|
instanceId, body.connectorType, list(plainConfig.keys())
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
if existing:
|
||||||
|
configId = existing[0].get("id")
|
||||||
|
updatePayload = {
|
||||||
|
"connectorType": body.connectorType or "",
|
||||||
|
"displayLabel": body.displayLabel or "",
|
||||||
|
"isActive": True,
|
||||||
|
}
|
||||||
|
if plainConfig:
|
||||||
|
existingEnc = existing[0].get("encryptedConfig") or ""
|
||||||
|
merged = {}
|
||||||
|
if existingEnc:
|
||||||
|
try:
|
||||||
|
merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for k, v in plainConfig.items():
|
||||||
|
if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER:
|
||||||
|
merged[k] = v
|
||||||
|
updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig")
|
||||||
|
interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload)
|
||||||
|
await refreshChartSilently(interface, instanceId)
|
||||||
|
return {"message": "Accounting config updated", "id": configId}
|
||||||
|
|
||||||
|
if not plainConfig:
|
||||||
|
return None # Signal to route handler: raise 400
|
||||||
|
|
||||||
|
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
|
||||||
|
configRecord = {
|
||||||
|
"id": str(_uuid.uuid4()),
|
||||||
|
"featureInstanceId": instanceId,
|
||||||
|
"connectorType": body.connectorType or "",
|
||||||
|
"displayLabel": body.displayLabel or "",
|
||||||
|
"encryptedConfig": encryptedConfig,
|
||||||
|
"isActive": True,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
}
|
||||||
|
interface.db.recordCreate(TrusteeAccountingConfig, configRecord)
|
||||||
|
await refreshChartSilently(interface, instanceId)
|
||||||
|
return {"message": "Accounting config created", "id": configRecord["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def getImportStatus(interface, instanceId: str) -> Dict[str, Any]:
|
||||||
|
"""Get counts of imported TrusteeData* records for this instance."""
|
||||||
|
from .datamodelFeatureTrustee import (
|
||||||
|
TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine,
|
||||||
|
TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig,
|
||||||
|
)
|
||||||
|
filt = {"featureInstanceId": instanceId}
|
||||||
|
counts = {
|
||||||
|
"accounts": len(interface.db.getRecordset(TrusteeDataAccount, recordFilter=filt) or []),
|
||||||
|
"journalEntries": len(interface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=filt) or []),
|
||||||
|
"journalLines": len(interface.db.getRecordset(TrusteeDataJournalLine, recordFilter=filt) or []),
|
||||||
|
"contacts": len(interface.db.getRecordset(TrusteeDataContact, recordFilter=filt) or []),
|
||||||
|
"accountBalances": len(interface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=filt) or []),
|
||||||
|
}
|
||||||
|
cfgRecords = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
|
||||||
|
if cfgRecords:
|
||||||
|
cfg = cfgRecords[0]
|
||||||
|
counts["lastSyncAt"] = cfg.get("lastSyncAt")
|
||||||
|
counts["lastSyncStatus"] = cfg.get("lastSyncStatus")
|
||||||
|
counts["lastSyncErrorMessage"] = cfg.get("lastSyncErrorMessage")
|
||||||
|
counts["lastSyncDateFrom"] = cfg.get("lastSyncDateFrom")
|
||||||
|
counts["lastSyncDateTo"] = cfg.get("lastSyncDateTo")
|
||||||
|
counts["lastSyncCounts"] = cfg.get("lastSyncCounts")
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def wipeImportedData(interface, instanceId: str) -> Dict[str, Any]:
|
||||||
|
"""Delete all TrusteeData* rows imported for this instance and reset sync markers."""
|
||||||
|
from .datamodelFeatureTrustee import (
|
||||||
|
TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine,
|
||||||
|
TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig,
|
||||||
|
)
|
||||||
|
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache
|
||||||
|
|
||||||
|
removed: Dict[str, int] = {}
|
||||||
|
for tableName, model in [
|
||||||
|
("accounts", TrusteeDataAccount),
|
||||||
|
("journalEntries", TrusteeDataJournalEntry),
|
||||||
|
("journalLines", TrusteeDataJournalLine),
|
||||||
|
("contacts", TrusteeDataContact),
|
||||||
|
("accountBalances", TrusteeDataAccountBalance),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
removed[tableName] = int(interface.db.recordDeleteWhere(model, {"featureInstanceId": instanceId}) or 0)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("wipeImportedData: failed for %s: %s", tableName, ex)
|
||||||
|
removed[tableName] = 0
|
||||||
|
|
||||||
|
cfgRecords = interface.db.getRecordset(
|
||||||
|
TrusteeAccountingConfig,
|
||||||
|
recordFilter={"featureInstanceId": instanceId, "isActive": True},
|
||||||
|
)
|
||||||
|
if cfgRecords:
|
||||||
|
cfgId = cfgRecords[0].get("id")
|
||||||
|
if cfgId:
|
||||||
|
try:
|
||||||
|
interface.db.recordModify(TrusteeAccountingConfig, cfgId, {
|
||||||
|
"lastSyncAt": None,
|
||||||
|
"lastSyncStatus": None,
|
||||||
|
"lastSyncErrorMessage": None,
|
||||||
|
"lastSyncDateFrom": None,
|
||||||
|
"lastSyncDateTo": None,
|
||||||
|
"lastSyncCounts": None,
|
||||||
|
})
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("wipeImportedData: failed to reset lastSync* on cfg %s: %s", cfgId, ex)
|
||||||
|
|
||||||
|
cacheCleared = clearFeatureQueryCache(instanceId)
|
||||||
|
logger.info("wipeImportedData instance=%s removed=%s cacheCleared=%s", instanceId, removed, cacheCleared)
|
||||||
|
return {
|
||||||
|
"removed": removed,
|
||||||
|
"totalRemoved": sum(removed.values()),
|
||||||
|
"cacheCleared": cacheCleared,
|
||||||
|
"featureInstanceId": instanceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def exportAccountingData(interface, instanceId: str, mandateId: str) -> Dict[str, Any]:
|
||||||
|
"""Build the export payload for all TrusteeData* tables for this instance."""
|
||||||
|
from .datamodelFeatureTrustee import (
|
||||||
|
TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine,
|
||||||
|
TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig,
|
||||||
|
)
|
||||||
|
_filter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
|
tables: Dict[str, Any] = {}
|
||||||
|
for tableName, model in [
|
||||||
|
("TrusteeDataAccount", TrusteeDataAccount),
|
||||||
|
("TrusteeDataJournalEntry", TrusteeDataJournalEntry),
|
||||||
|
("TrusteeDataJournalLine", TrusteeDataJournalLine),
|
||||||
|
("TrusteeDataContact", TrusteeDataContact),
|
||||||
|
("TrusteeDataAccountBalance", TrusteeDataAccountBalance),
|
||||||
|
]:
|
||||||
|
records = interface.db.getRecordset(model, recordFilter=_filter) or []
|
||||||
|
tables[tableName] = records
|
||||||
|
|
||||||
|
cfgRecords = interface.db.getRecordset(
|
||||||
|
TrusteeAccountingConfig,
|
||||||
|
recordFilter={"featureInstanceId": instanceId, "isActive": True},
|
||||||
|
)
|
||||||
|
syncInfo = {}
|
||||||
|
if cfgRecords:
|
||||||
|
cfg = cfgRecords[0]
|
||||||
|
syncInfo = {
|
||||||
|
"connectorType": cfg.get("connectorType", ""),
|
||||||
|
"lastSyncAt": cfg.get("lastSyncAt"),
|
||||||
|
"lastSyncStatus": cfg.get("lastSyncStatus", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exportedAt": time.time(),
|
||||||
|
"featureInstanceId": instanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"syncInfo": syncInfo,
|
||||||
|
"tables": tables,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Background Job Handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE = "trusteeAccountingPush"
|
||||||
|
TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE = "trusteeAccountingSync"
|
||||||
|
|
||||||
|
|
||||||
|
async def accountingPushJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]:
|
||||||
|
"""BackgroundJob handler: pushes a batch of positions to the external accounting system."""
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
from .accounting.accountingBridge import AccountingBridge, SyncResult
|
||||||
|
from .interfaceFeatureTrustee import getInterface
|
||||||
|
|
||||||
|
instanceId = job["featureInstanceId"]
|
||||||
|
mandateId = job["mandateId"]
|
||||||
|
payload = job.get("payload") or {}
|
||||||
|
positionIds: List[str] = list(payload.get("positionIds") or [])
|
||||||
|
if not positionIds:
|
||||||
|
return {"total": 0, "success": 0, "skipped": 0, "errors": 0, "results": []}
|
||||||
|
|
||||||
|
rootUser = getRootUser()
|
||||||
|
interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
bridge = AccountingBridge(interface)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
total = len(positionIds)
|
||||||
|
progressCb(
|
||||||
|
2,
|
||||||
|
messageKey="Sync wird vorbereitet ({total} Position(en))...",
|
||||||
|
messageParams={"total": total},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector, plainConfig, configRecord = await bridge._resolveConnectorAndConfig(instanceId)
|
||||||
|
except Exception as resolveErr:
|
||||||
|
logger.exception("Accounting push: failed to resolve connector/config")
|
||||||
|
progressCb(100, messageKey="Verbindungsaufbau fehlgeschlagen.")
|
||||||
|
raise resolveErr
|
||||||
|
|
||||||
|
if not connector or not plainConfig:
|
||||||
|
results = [SyncResult(success=False, errorMessage="No active accounting configuration found") for _ in positionIds]
|
||||||
|
progressCb(100, messageKey="Keine aktive Buchhaltungs-Konfiguration gefunden.")
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"success": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"errors": len(results),
|
||||||
|
"results": [r.model_dump() for r in results],
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, positionId in enumerate(positionIds, start=1):
|
||||||
|
result = await bridge.pushPositionToAccounting(
|
||||||
|
instanceId,
|
||||||
|
positionId,
|
||||||
|
_resolvedConnector=connector,
|
||||||
|
_resolvedPlainConfig=plainConfig,
|
||||||
|
_resolvedConfigRecord=configRecord,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
pct = 5 + int(90 * index / total)
|
||||||
|
progressCb(
|
||||||
|
pct,
|
||||||
|
messageKey="Position {index}/{total} verarbeitet",
|
||||||
|
messageParams={"index": index, "total": total},
|
||||||
|
)
|
||||||
|
|
||||||
|
skipped = [r for r in results if not r.success and r.errorMessage and "already synced" in r.errorMessage]
|
||||||
|
failed = [r for r in results if not r.success and r not in skipped]
|
||||||
|
if skipped:
|
||||||
|
logger.info("Accounting sync: %s position(s) already synced, skipped", len(skipped))
|
||||||
|
if failed:
|
||||||
|
logger.warning(
|
||||||
|
"Accounting sync had %s failure(s): %s",
|
||||||
|
len(failed),
|
||||||
|
"; ".join(r.errorMessage or "unknown" for r in failed[:3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
progressCb(100, messageKey="Sync abgeschlossen.")
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"success": sum(1 for r in results if r.success),
|
||||||
|
"skipped": len(skipped),
|
||||||
|
"errors": len(failed),
|
||||||
|
"results": [r.model_dump() for r in results],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def accountingSyncJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]:
|
||||||
|
"""BackgroundJob handler: imports accounting data from the external system."""
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
from .accounting.accountingDataSync import AccountingDataSync
|
||||||
|
from .interfaceFeatureTrustee import getInterface
|
||||||
|
|
||||||
|
instanceId = job["featureInstanceId"]
|
||||||
|
mandateId = job["mandateId"]
|
||||||
|
payload = job.get("payload") or {}
|
||||||
|
rootUser = getRootUser()
|
||||||
|
|
||||||
|
progressCb(5, messageKey="Initialisiere Import...")
|
||||||
|
interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
sync = AccountingDataSync(interface)
|
||||||
|
progressCb(10, messageKey="Verbinde mit Buchhaltungssystem...")
|
||||||
|
result = await sync.importData(
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
dateFrom=payload.get("dateFrom"),
|
||||||
|
dateTo=payload.get("dateTo"),
|
||||||
|
progressCb=progressCb,
|
||||||
|
)
|
||||||
|
progressCb(100, messageKey="Import abgeschlossen.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Register background job handlers
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler
|
||||||
|
registerJobHandler(TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE, accountingPushJobHandler)
|
||||||
|
registerJobHandler(TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, accountingSyncJobHandler)
|
||||||
|
except Exception as _regErr:
|
||||||
|
logger.warning("Failed to register accounting job handlers: %s", _regErr)
|
||||||
|
|
@ -1097,3 +1097,27 @@ def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to cascade-delete trustee data for mandate {mandateId}: {e}")
|
logger.warning(f"Failed to cascade-delete trustee data for mandate {mandateId}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def onUserDelete(userId: str, currentUser) -> dict:
|
||||||
|
"""Delete/anonymize user data from the trustee database (GDPR)."""
|
||||||
|
from modules.system.gdprDeletion import deleteUserDataFromDatabase
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
dbName = "poweron_trustee"
|
||||||
|
try:
|
||||||
|
db = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase=dbName,
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
stats = deleteUserDataFromDatabase(db, userId, dbName)
|
||||||
|
db.close()
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"onUserDelete trustee failed: {e}")
|
||||||
|
return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1395,6 +1395,19 @@ def delete_position(
|
||||||
|
|
||||||
# ===== Accounting Integration Endpoints =====
|
# ===== Accounting Integration Endpoints =====
|
||||||
|
|
||||||
|
from .handlerTrusteeAccounting import (
|
||||||
|
SaveAccountingConfigBody,
|
||||||
|
readAccountingConfig,
|
||||||
|
saveAccountingConfig as _saveAccountingConfig,
|
||||||
|
refreshChartSilently as _refreshChartSilently,
|
||||||
|
getImportStatus as _getImportStatus,
|
||||||
|
wipeImportedData as _wipeImportedData,
|
||||||
|
exportAccountingData as _exportAccountingData,
|
||||||
|
TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE,
|
||||||
|
TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/accounting/connectors")
|
@router.get("/{instanceId}/accounting/connectors")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_available_accounting_connectors(
|
def get_available_accounting_connectors(
|
||||||
|
|
@ -1408,23 +1421,6 @@ def get_available_accounting_connectors(
|
||||||
return getAccountingRegistry().getAvailableConnectors()
|
return getAccountingRegistry().getAvailableConnectors()
|
||||||
|
|
||||||
|
|
||||||
# Placeholder returned for secret config fields so frontend can prefill form without sending real secrets.
|
|
||||||
_CONFIG_PLACEHOLDER = "***"
|
|
||||||
|
|
||||||
|
|
||||||
def _getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]:
|
|
||||||
"""Build config with secret values replaced by placeholder for GET response."""
|
|
||||||
from .accounting.accountingRegistry import getAccountingRegistry
|
|
||||||
connector = getAccountingRegistry().getConnector(connectorType)
|
|
||||||
if not connector:
|
|
||||||
return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()}
|
|
||||||
secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret}
|
|
||||||
return {
|
|
||||||
k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "")
|
|
||||||
for k, v in (plainConfig or {}).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/accounting/config")
|
@router.get("/{instanceId}/accounting/config")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_accounting_config(
|
def get_accounting_config(
|
||||||
|
|
@ -1432,33 +1428,10 @@ def get_accounting_config(
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Get the active accounting config for this instance. Credentials are masked (secret fields = ***) for form prefill."""
|
"""Get the active accounting config for this instance. Credentials are masked."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
return readAccountingConfig(interface, instanceId)
|
||||||
from modules.shared.configuration import decryptValue
|
|
||||||
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
|
|
||||||
if not records:
|
|
||||||
return {"configured": False}
|
|
||||||
record = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
|
||||||
encryptedConfig = record.pop("encryptedConfig", None)
|
|
||||||
record["configured"] = True
|
|
||||||
if encryptedConfig:
|
|
||||||
try:
|
|
||||||
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
|
|
||||||
record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
|
|
||||||
except Exception:
|
|
||||||
record["configMasked"] = {}
|
|
||||||
else:
|
|
||||||
record["configMasked"] = {}
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
class SaveAccountingConfigBody(BaseModel):
|
|
||||||
"""Request body for saving accounting config. Ensures 'config' is present and used."""
|
|
||||||
connectorType: str = ""
|
|
||||||
displayLabel: str = ""
|
|
||||||
config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/accounting/config", status_code=201)
|
@router.post("/{instanceId}/accounting/config", status_code=201)
|
||||||
|
|
@ -1469,73 +1442,16 @@ async def save_accounting_config(
|
||||||
body: SaveAccountingConfigBody = Body(...),
|
body: SaveAccountingConfigBody = Body(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Save or update the accounting config for this instance.
|
"""Save or update the accounting config for this instance."""
|
||||||
|
|
||||||
Body: { connectorType, displayLabel, config: { clientName, apiKey, ... } }
|
|
||||||
The 'config' object is stored encrypted; without it credentials would be empty in DB.
|
|
||||||
"""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
result = await _saveAccountingConfig(interface, instanceId, mandateId, body)
|
||||||
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
if result is None:
|
||||||
from modules.shared.configuration import encryptValue
|
|
||||||
|
|
||||||
plainConfig = body.config if isinstance(body.config, dict) else {}
|
|
||||||
# When updating, empty config is normal (frontend never receives credentials from GET).
|
|
||||||
# Do not overwrite encryptedConfig with empty – keep existing credentials.
|
|
||||||
if not plainConfig and body.connectorType:
|
|
||||||
logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)")
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Accounting config save: instanceId=%s connectorType=%s configKeys=%s",
|
|
||||||
instanceId, body.connectorType, list(plainConfig.keys())
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
|
|
||||||
if existing:
|
|
||||||
configId = existing[0].get("id")
|
|
||||||
updatePayload = {
|
|
||||||
"connectorType": body.connectorType or "",
|
|
||||||
"displayLabel": body.displayLabel or "",
|
|
||||||
"isActive": True,
|
|
||||||
}
|
|
||||||
if plainConfig:
|
|
||||||
# Merge with existing: placeholder or empty = keep existing value (so form prefill does not overwrite secrets).
|
|
||||||
from modules.shared.configuration import decryptValue
|
|
||||||
existingEnc = existing[0].get("encryptedConfig") or ""
|
|
||||||
merged = {}
|
|
||||||
if existingEnc:
|
|
||||||
try:
|
|
||||||
merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig"))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for k, v in plainConfig.items():
|
|
||||||
if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER:
|
|
||||||
merged[k] = v
|
|
||||||
updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig")
|
|
||||||
interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload)
|
|
||||||
await _refreshChartSilently(interface, instanceId)
|
|
||||||
return {"message": "Accounting config updated", "id": configId}
|
|
||||||
|
|
||||||
if not plainConfig:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=routeApiMsg("config is required for new integration (e.g. clientName, apiKey).")
|
detail=routeApiMsg("config is required for new integration (e.g. clientName, apiKey).")
|
||||||
)
|
)
|
||||||
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
|
return result
|
||||||
|
|
||||||
configRecord = {
|
|
||||||
"id": str(uuid.uuid4()),
|
|
||||||
"featureInstanceId": instanceId,
|
|
||||||
"connectorType": body.connectorType or "",
|
|
||||||
"displayLabel": body.displayLabel or "",
|
|
||||||
"encryptedConfig": encryptedConfig,
|
|
||||||
"isActive": True,
|
|
||||||
"mandateId": mandateId,
|
|
||||||
}
|
|
||||||
interface.db.recordCreate(TrusteeAccountingConfig, configRecord)
|
|
||||||
await _refreshChartSilently(interface, instanceId)
|
|
||||||
return {"message": "Accounting config created", "id": configRecord["id"]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/accounting/test-connection")
|
@router.post("/{instanceId}/accounting/test-connection")
|
||||||
|
|
@ -1545,7 +1461,7 @@ async def test_accounting_connection(
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Test the connection to the configured accounting system. On success, refreshes the local chart-of-accounts cache."""
|
"""Test the connection to the configured accounting system."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .accounting.accountingBridge import AccountingBridge
|
from .accounting.accountingBridge import AccountingBridge
|
||||||
|
|
@ -1581,7 +1497,7 @@ async def get_chart_of_accounts(
|
||||||
accountType: Optional[str] = Query(None, description="Filter by type: expense, asset, liability, revenue"),
|
accountType: Optional[str] = Query(None, description="Filter by type: expense, asset, liability, revenue"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Load the chart of accounts from the connected accounting system. Optional filter by accountType."""
|
"""Load the chart of accounts from the connected accounting system."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .accounting.accountingBridge import AccountingBridge
|
from .accounting.accountingBridge import AccountingBridge
|
||||||
|
|
@ -1590,17 +1506,6 @@ async def get_chart_of_accounts(
|
||||||
return [c.model_dump() for c in charts]
|
return [c.model_dump() for c in charts]
|
||||||
|
|
||||||
|
|
||||||
async def _refreshChartSilently(interface, instanceId: str) -> None:
|
|
||||||
"""Best-effort chart-of-accounts cache refresh. Logs but does not raise on failure."""
|
|
||||||
try:
|
|
||||||
from .accounting.accountingBridge import AccountingBridge
|
|
||||||
bridge = AccountingBridge(interface)
|
|
||||||
charts = await bridge.refreshChartOfAccounts(instanceId)
|
|
||||||
logger.info(f"Chart cache refreshed: {len(charts)} entries for instance {instanceId}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Chart cache refresh failed (non-critical): {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/accounting/refresh-chart")
|
@router.post("/{instanceId}/accounting/refresh-chart")
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def refresh_chart_of_accounts(
|
async def refresh_chart_of_accounts(
|
||||||
|
|
@ -1617,108 +1522,6 @@ async def refresh_chart_of_accounts(
|
||||||
return {"message": f"Chart of accounts refreshed: {len(charts)} entries", "count": len(charts)}
|
return {"message": f"Chart of accounts refreshed: {len(charts)} entries", "count": len(charts)}
|
||||||
|
|
||||||
|
|
||||||
TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE = "trusteeAccountingPush"
|
|
||||||
|
|
||||||
|
|
||||||
async def _trusteeAccountingPushJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]:
|
|
||||||
"""BackgroundJob handler: pushes a batch of positions to the external
|
|
||||||
accounting system. Runs in the worker without blocking the original HTTP
|
|
||||||
request, so the user can continue navigating while the sync runs.
|
|
||||||
|
|
||||||
Reads inputs from `job["payload"]` (`positionIds`) and reports incremental
|
|
||||||
progress via `progressCb(percent, message)`. The job result has the same
|
|
||||||
shape that the legacy synchronous endpoint used to return.
|
|
||||||
"""
|
|
||||||
from modules.security.rootAccess import getRootUser
|
|
||||||
from .accounting.accountingBridge import AccountingBridge
|
|
||||||
|
|
||||||
instanceId = job["featureInstanceId"]
|
|
||||||
mandateId = job["mandateId"]
|
|
||||||
payload = job.get("payload") or {}
|
|
||||||
positionIds: List[str] = list(payload.get("positionIds") or [])
|
|
||||||
if not positionIds:
|
|
||||||
return {"total": 0, "success": 0, "skipped": 0, "errors": 0, "results": []}
|
|
||||||
|
|
||||||
rootUser = getRootUser()
|
|
||||||
interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId)
|
|
||||||
bridge = AccountingBridge(interface)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
total = len(positionIds)
|
|
||||||
progressCb(
|
|
||||||
2,
|
|
||||||
messageKey="Sync wird vorbereitet ({total} Position(en))...",
|
|
||||||
messageParams={"total": total},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve connector + plain config once to avoid decryption rate-limits
|
|
||||||
# (mirrors the optimisation in pushBatchToAccounting). We push positions
|
|
||||||
# one-by-one inside the job so we can emit incremental progress and so
|
|
||||||
# one bad row never aborts the rest.
|
|
||||||
from .accounting.accountingBridge import SyncResult
|
|
||||||
try:
|
|
||||||
connector, plainConfig, configRecord = await bridge._resolveConnectorAndConfig(instanceId)
|
|
||||||
except Exception as resolveErr:
|
|
||||||
logger.exception("Accounting push: failed to resolve connector/config")
|
|
||||||
progressCb(100, messageKey="Verbindungsaufbau fehlgeschlagen.")
|
|
||||||
raise resolveErr
|
|
||||||
|
|
||||||
if not connector or not plainConfig:
|
|
||||||
results = [SyncResult(success=False, errorMessage="No active accounting configuration found") for _ in positionIds]
|
|
||||||
progressCb(100, messageKey="Keine aktive Buchhaltungs-Konfiguration gefunden.")
|
|
||||||
return {
|
|
||||||
"total": len(results),
|
|
||||||
"success": 0,
|
|
||||||
"skipped": 0,
|
|
||||||
"errors": len(results),
|
|
||||||
"results": [r.model_dump() for r in results],
|
|
||||||
}
|
|
||||||
|
|
||||||
for index, positionId in enumerate(positionIds, start=1):
|
|
||||||
result = await bridge.pushPositionToAccounting(
|
|
||||||
instanceId,
|
|
||||||
positionId,
|
|
||||||
_resolvedConnector=connector,
|
|
||||||
_resolvedPlainConfig=plainConfig,
|
|
||||||
_resolvedConfigRecord=configRecord,
|
|
||||||
)
|
|
||||||
results.append(result)
|
|
||||||
# Reserve 5..95% for the push loop, keep the tail for summary.
|
|
||||||
pct = 5 + int(90 * index / total)
|
|
||||||
progressCb(
|
|
||||||
pct,
|
|
||||||
messageKey="Position {index}/{total} verarbeitet",
|
|
||||||
messageParams={"index": index, "total": total},
|
|
||||||
)
|
|
||||||
|
|
||||||
skipped = [r for r in results if not r.success and r.errorMessage and "already synced" in r.errorMessage]
|
|
||||||
failed = [r for r in results if not r.success and r not in skipped]
|
|
||||||
if skipped:
|
|
||||||
logger.info("Accounting sync: %s position(s) already synced, skipped", len(skipped))
|
|
||||||
if failed:
|
|
||||||
logger.warning(
|
|
||||||
"Accounting sync had %s failure(s): %s",
|
|
||||||
len(failed),
|
|
||||||
"; ".join(r.errorMessage or "unknown" for r in failed[:3]),
|
|
||||||
)
|
|
||||||
|
|
||||||
progressCb(100, messageKey="Sync abgeschlossen.")
|
|
||||||
return {
|
|
||||||
"total": len(results),
|
|
||||||
"success": sum(1 for r in results if r.success),
|
|
||||||
"skipped": len(skipped),
|
|
||||||
"errors": len(failed),
|
|
||||||
"results": [r.model_dump() for r in results],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler as _registerPushJobHandler
|
|
||||||
_registerPushJobHandler(TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE, _trusteeAccountingPushJobHandler)
|
|
||||||
except Exception as _pushRegErr:
|
|
||||||
logger.warning("Failed to register trusteeAccountingPush job handler: %s", _pushRegErr)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/accounting/sync", status_code=status.HTTP_202_ACCEPTED)
|
@router.post("/{instanceId}/accounting/sync", status_code=status.HTTP_202_ACCEPTED)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def sync_positions_to_accounting(
|
async def sync_positions_to_accounting(
|
||||||
|
|
@ -1727,21 +1530,10 @@ async def sync_positions_to_accounting(
|
||||||
data: Dict[str, Any] = Body(...),
|
data: Dict[str, Any] = Body(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Submit a background job that pushes positions to the accounting system.
|
"""Submit a background job that pushes positions to the accounting system."""
|
||||||
|
|
||||||
Body: ``{ positionIds: [...] }``
|
|
||||||
|
|
||||||
Returns ``{ jobId, status: "pending" }`` immediately so the user is not
|
|
||||||
blocked while the (potentially long) external accounting calls run.
|
|
||||||
Clients poll ``GET /api/jobs/{jobId}`` until status is ``SUCCESS`` /
|
|
||||||
``ERROR`` and then read the same ``{ total, success, skipped, errors,
|
|
||||||
results }`` payload from ``job.result`` that the legacy synchronous
|
|
||||||
endpoint returned.
|
|
||||||
"""
|
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
||||||
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
positionIds = data.get("positionIds", [])
|
positionIds = data.get("positionIds", [])
|
||||||
if not positionIds:
|
if not positionIds:
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required"))
|
||||||
|
|
@ -1771,11 +1563,7 @@ async def sync_single_position_to_accounting(
|
||||||
bridge = AccountingBridge(interface)
|
bridge = AccountingBridge(interface)
|
||||||
result = await bridge.pushPositionToAccounting(instanceId, positionId)
|
result = await bridge.pushPositionToAccounting(instanceId, positionId)
|
||||||
if not result.success:
|
if not result.success:
|
||||||
logger.warning(
|
logger.warning("Accounting sync failed for positionId=%s: %s", positionId, result.errorMessage or "unknown")
|
||||||
"Accounting sync failed for positionId=%s: %s",
|
|
||||||
positionId,
|
|
||||||
result.errorMessage or "unknown",
|
|
||||||
)
|
|
||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1791,8 +1579,7 @@ def get_sync_status(
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .datamodelFeatureTrustee import TrusteeAccountingSync
|
from .datamodelFeatureTrustee import TrusteeAccountingSync
|
||||||
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId})
|
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId})
|
||||||
items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]
|
return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]}
|
||||||
return {"items": items}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/accounting/sync-status/{positionId}")
|
@router.get("/{instanceId}/accounting/sync-status/{positionId}")
|
||||||
|
|
@ -1808,52 +1595,11 @@ def get_position_sync_status(
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .datamodelFeatureTrustee import TrusteeAccountingSync
|
from .datamodelFeatureTrustee import TrusteeAccountingSync
|
||||||
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId})
|
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId})
|
||||||
items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]
|
return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]}
|
||||||
return {"items": items}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Accounting Data Import =====
|
# ===== Accounting Data Import =====
|
||||||
|
|
||||||
TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE = "trusteeAccountingSync"
|
|
||||||
|
|
||||||
|
|
||||||
async def _trusteeAccountingSyncJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]:
|
|
||||||
"""BackgroundJob handler: imports accounting data from the external system.
|
|
||||||
|
|
||||||
Reads inputs from `job["payload"]` (dateFrom, dateTo, userId) and runs
|
|
||||||
`AccountingDataSync.importData(...)` in the worker's event loop without
|
|
||||||
blocking the original HTTP request that submitted the job.
|
|
||||||
"""
|
|
||||||
from modules.security.rootAccess import getRootUser
|
|
||||||
from .accounting.accountingDataSync import AccountingDataSync
|
|
||||||
|
|
||||||
instanceId = job["featureInstanceId"]
|
|
||||||
mandateId = job["mandateId"]
|
|
||||||
payload = job.get("payload") or {}
|
|
||||||
rootUser = getRootUser()
|
|
||||||
|
|
||||||
progressCb(5, messageKey="Initialisiere Import...")
|
|
||||||
interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId)
|
|
||||||
sync = AccountingDataSync(interface)
|
|
||||||
progressCb(10, messageKey="Verbinde mit Buchhaltungssystem...")
|
|
||||||
result = await sync.importData(
|
|
||||||
featureInstanceId=instanceId,
|
|
||||||
mandateId=mandateId,
|
|
||||||
dateFrom=payload.get("dateFrom"),
|
|
||||||
dateTo=payload.get("dateTo"),
|
|
||||||
progressCb=progressCb,
|
|
||||||
)
|
|
||||||
progressCb(100, messageKey="Import abgeschlossen.")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler
|
|
||||||
registerJobHandler(TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, _trusteeAccountingSyncJobHandler)
|
|
||||||
except Exception as _regErr:
|
|
||||||
logger.warning("Failed to register trusteeAccountingSync job handler: %s", _regErr)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/accounting/import-data", status_code=status.HTTP_202_ACCEPTED)
|
@router.post("/{instanceId}/accounting/import-data", status_code=status.HTTP_202_ACCEPTED)
|
||||||
@limiter.limit("3/minute")
|
@limiter.limit("3/minute")
|
||||||
async def import_accounting_data(
|
async def import_accounting_data(
|
||||||
|
|
@ -1862,18 +1608,11 @@ async def import_accounting_data(
|
||||||
data: Dict[str, Any] = Body(default={}),
|
data: Dict[str, Any] = Body(default={}),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Submit a background job to import accounting data.
|
"""Submit a background job to import accounting data."""
|
||||||
|
|
||||||
Returns immediately with `{ jobId }`; clients poll `GET /api/jobs/{jobId}`
|
|
||||||
until status is SUCCESS / ERROR.
|
|
||||||
"""
|
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
||||||
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
payload = {
|
payload = {"dateFrom": data.get("dateFrom"), "dateTo": data.get("dateTo")}
|
||||||
"dateFrom": data.get("dateFrom"),
|
|
||||||
"dateTo": data.get("dateTo"),
|
|
||||||
}
|
|
||||||
jobId = await startJob(
|
jobId = await startJob(
|
||||||
TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE,
|
TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE,
|
||||||
payload,
|
payload,
|
||||||
|
|
@ -1894,28 +1633,7 @@ def get_import_status(
|
||||||
"""Get counts of imported TrusteeData* records for this instance."""
|
"""Get counts of imported TrusteeData* records for this instance."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .datamodelFeatureTrustee import (
|
return _getImportStatus(interface, instanceId)
|
||||||
TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine,
|
|
||||||
TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig,
|
|
||||||
)
|
|
||||||
filt = {"featureInstanceId": instanceId}
|
|
||||||
counts = {
|
|
||||||
"accounts": len(interface.db.getRecordset(TrusteeDataAccount, recordFilter=filt) or []),
|
|
||||||
"journalEntries": len(interface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=filt) or []),
|
|
||||||
"journalLines": len(interface.db.getRecordset(TrusteeDataJournalLine, recordFilter=filt) or []),
|
|
||||||
"contacts": len(interface.db.getRecordset(TrusteeDataContact, recordFilter=filt) or []),
|
|
||||||
"accountBalances": len(interface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=filt) or []),
|
|
||||||
}
|
|
||||||
cfgRecords = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
|
|
||||||
if cfgRecords:
|
|
||||||
cfg = cfgRecords[0]
|
|
||||||
counts["lastSyncAt"] = cfg.get("lastSyncAt")
|
|
||||||
counts["lastSyncStatus"] = cfg.get("lastSyncStatus")
|
|
||||||
counts["lastSyncErrorMessage"] = cfg.get("lastSyncErrorMessage")
|
|
||||||
counts["lastSyncDateFrom"] = cfg.get("lastSyncDateFrom")
|
|
||||||
counts["lastSyncDateTo"] = cfg.get("lastSyncDateTo")
|
|
||||||
counts["lastSyncCounts"] = cfg.get("lastSyncCounts")
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
# ===== AI Data Cache =====
|
# ===== AI Data Cache =====
|
||||||
|
|
@ -1927,12 +1645,7 @@ def clear_ai_data_cache(
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Clear ONLY the AI feature-data query result cache (in-memory, ~5 min TTL).
|
"""Clear the AI feature-data query result cache (in-memory)."""
|
||||||
|
|
||||||
Important: this does NOT touch the synchronised ``TrusteeData*`` tables.
|
|
||||||
The synced rows (chart of accounts, journal entries/lines, contacts, balances)
|
|
||||||
stay exactly as imported. To wipe those rows, use POST .../wipe-imported-data.
|
|
||||||
"""
|
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache
|
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache
|
||||||
removed = clearFeatureQueryCache(instanceId)
|
removed = clearFeatureQueryCache(instanceId)
|
||||||
|
|
@ -1946,66 +1659,10 @@ def wipe_imported_accounting_data(
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete every ``TrusteeData*`` row imported for this feature instance.
|
"""Delete all TrusteeData* rows imported for this feature instance and reset sync markers."""
|
||||||
|
|
||||||
Use when the source system was changed, test data needs to be cleared, or
|
|
||||||
the user suspects stale rows from earlier connector versions. Also resets
|
|
||||||
the ``lastSync*`` markers on the active config so the UI no longer reports
|
|
||||||
a stale "letzter Import" status. The connector configuration / credentials
|
|
||||||
remain untouched -- only synchronised payload data is removed.
|
|
||||||
"""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
from .datamodelFeatureTrustee import (
|
return _wipeImportedData(interface, instanceId)
|
||||||
TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine,
|
|
||||||
TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig,
|
|
||||||
)
|
|
||||||
from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache
|
|
||||||
|
|
||||||
removed: Dict[str, int] = {}
|
|
||||||
for tableName, model in [
|
|
||||||
("accounts", TrusteeDataAccount),
|
|
||||||
("journalEntries", TrusteeDataJournalEntry),
|
|
||||||
("journalLines", TrusteeDataJournalLine),
|
|
||||||
("contacts", TrusteeDataContact),
|
|
||||||
("accountBalances", TrusteeDataAccountBalance),
|
|
||||||
]:
|
|
||||||
try:
|
|
||||||
removed[tableName] = int(interface.db.recordDeleteWhere(model, {"featureInstanceId": instanceId}) or 0)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning("wipeImportedData: failed for %s: %s", tableName, ex)
|
|
||||||
removed[tableName] = 0
|
|
||||||
|
|
||||||
cfgRecords = interface.db.getRecordset(
|
|
||||||
TrusteeAccountingConfig,
|
|
||||||
recordFilter={"featureInstanceId": instanceId, "isActive": True},
|
|
||||||
)
|
|
||||||
if cfgRecords:
|
|
||||||
cfgId = cfgRecords[0].get("id")
|
|
||||||
if cfgId:
|
|
||||||
try:
|
|
||||||
interface.db.recordModify(TrusteeAccountingConfig, cfgId, {
|
|
||||||
"lastSyncAt": None,
|
|
||||||
"lastSyncStatus": None,
|
|
||||||
"lastSyncErrorMessage": None,
|
|
||||||
"lastSyncDateFrom": None,
|
|
||||||
"lastSyncDateTo": None,
|
|
||||||
"lastSyncCounts": None,
|
|
||||||
})
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning("wipeImportedData: failed to reset lastSync* on cfg %s: %s", cfgId, ex)
|
|
||||||
|
|
||||||
cacheCleared = clearFeatureQueryCache(instanceId)
|
|
||||||
logger.info(
|
|
||||||
"wipeImportedData instance=%s removed=%s cacheCleared=%s",
|
|
||||||
instanceId, removed, cacheCleared,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"removed": removed,
|
|
||||||
"totalRemoved": sum(removed.values()),
|
|
||||||
"cacheCleared": cacheCleared,
|
|
||||||
"featureInstanceId": instanceId,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Data Export =====
|
# ===== Data Export =====
|
||||||
|
|
@ -2017,52 +1674,10 @@ def export_accounting_data(
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Export all TrusteeData* tables for this instance as a JSON download (admin only)."""
|
"""Export all TrusteeData* tables for this instance as a JSON download."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
from .datamodelFeatureTrustee import (
|
|
||||||
TrusteeDataAccount,
|
|
||||||
TrusteeDataJournalEntry,
|
|
||||||
TrusteeDataJournalLine,
|
|
||||||
TrusteeDataContact,
|
|
||||||
TrusteeDataAccountBalance,
|
|
||||||
TrusteeAccountingConfig,
|
|
||||||
)
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
_filter = {"featureInstanceId": instanceId}
|
payload = _exportAccountingData(interface, instanceId, mandateId)
|
||||||
|
|
||||||
tables: Dict[str, Any] = {}
|
|
||||||
for tableName, model in [
|
|
||||||
("TrusteeDataAccount", TrusteeDataAccount),
|
|
||||||
("TrusteeDataJournalEntry", TrusteeDataJournalEntry),
|
|
||||||
("TrusteeDataJournalLine", TrusteeDataJournalLine),
|
|
||||||
("TrusteeDataContact", TrusteeDataContact),
|
|
||||||
("TrusteeDataAccountBalance", TrusteeDataAccountBalance),
|
|
||||||
]:
|
|
||||||
records = interface.db.getRecordset(model, recordFilter=_filter) or []
|
|
||||||
tables[tableName] = records
|
|
||||||
|
|
||||||
cfgRecords = interface.db.getRecordset(
|
|
||||||
TrusteeAccountingConfig,
|
|
||||||
recordFilter={"featureInstanceId": instanceId, "isActive": True},
|
|
||||||
)
|
|
||||||
syncInfo = {}
|
|
||||||
if cfgRecords:
|
|
||||||
cfg = cfgRecords[0]
|
|
||||||
syncInfo = {
|
|
||||||
"connectorType": cfg.get("connectorType", ""),
|
|
||||||
"lastSyncAt": cfg.get("lastSyncAt"),
|
|
||||||
"lastSyncStatus": cfg.get("lastSyncStatus", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"exportedAt": time.time(),
|
|
||||||
"featureInstanceId": instanceId,
|
|
||||||
"mandateId": mandateId,
|
|
||||||
"syncInfo": syncInfo,
|
|
||||||
"tables": tables,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
jsonBytes = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
return Response(
|
return Response(
|
||||||
content=jsonBytes,
|
content=jsonBytes,
|
||||||
|
|
|
||||||
3
modules/features/trustee/workflows/__init__.py
Normal file
3
modules/features/trustee/workflows/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Trustee feature-owned workflow methods."""
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Dict, Any, List, Optional, Tuple
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument, ChatDocument, ChatMessage
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument, ChatDocument, ChatMessage
|
||||||
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
|
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
|
||||||
from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum
|
from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -111,7 +111,7 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
logger.warning(f"Mandate retention purge failed: {e}")
|
logger.warning(f"Mandate retention purge failed: {e}")
|
||||||
|
|
||||||
# Let features run their own bootstrap logic via lifecycle hooks
|
# Let features run their own bootstrap logic via lifecycle hooks
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
for _fCode, _fMod in loadFeatureMainModules().items():
|
for _fCode, _fMod in loadFeatureMainModules().items():
|
||||||
_bootHook = getattr(_fMod, "onBootstrap", None)
|
_bootHook = getattr(_fMod, "onBootstrap", None)
|
||||||
if _bootHook:
|
if _bootHook:
|
||||||
|
|
@ -172,7 +172,7 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
|
||||||
"""
|
"""
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
|
|
||||||
logger.info("Initializing root mandate features")
|
logger.info("Initializing root mandate features")
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
|
||||||
def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None:
|
def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None:
|
||||||
"""Remove feature instances whose featureCode no longer exists in the codebase."""
|
"""Remove feature instances whose featureCode no longer exists in the codebase."""
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
|
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
activeCodes = set()
|
activeCodes = set()
|
||||||
|
|
@ -1144,7 +1144,7 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS
|
||||||
|
|
||||||
uiRules = []
|
uiRules = []
|
||||||
adminId = _getRoleId(db, "admin")
|
adminId = _getRoleId(db, "admin")
|
||||||
|
|
@ -1200,7 +1200,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS
|
||||||
|
|
||||||
# Template role IDs
|
# Template role IDs
|
||||||
adminId = _getRoleId(db, "admin")
|
adminId = _getRoleId(db, "admin")
|
||||||
|
|
|
||||||
|
|
@ -1579,7 +1579,7 @@ class AppObjects:
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
plan = BUILTIN_PLANS.get(planKey)
|
plan = BUILTIN_PLANS.get(planKey)
|
||||||
if not plan:
|
if not plan:
|
||||||
raise ValueError(f"Unknown plan: {planKey}")
|
raise ValueError(f"Unknown plan: {planKey}")
|
||||||
|
|
@ -1871,7 +1871,7 @@ class AppObjects:
|
||||||
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
|
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
for _fCode, _fMod in loadFeatureMainModules().items():
|
for _fCode, _fMod in loadFeatureMainModules().items():
|
||||||
_hook = getattr(_fMod, "onMandateDelete", None)
|
_hook = getattr(_fMod, "onMandateDelete", None)
|
||||||
if _hook:
|
if _hook:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from modules.datamodels.datamodelSubscription import (
|
||||||
getEffectiveLimits,
|
getEffectiveLimits,
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.shared.serviceExceptions import SubscriptionCapacityException
|
from modules.datamodels.serviceExceptions import SubscriptionCapacityException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ class FeatureInterface:
|
||||||
RuntimeError: If templates exist but cannot be copied.
|
RuntimeError: If templates exist but cannot be copied.
|
||||||
Caller decides whether to swallow or re-raise.
|
Caller decides whether to swallow or re-raise.
|
||||||
"""
|
"""
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
featureModule = mainModules.get(featureCode)
|
featureModule = mainModules.get(featureCode)
|
||||||
if not featureModule:
|
if not featureModule:
|
||||||
|
|
|
||||||
399
modules/routes/billingWebhookHandler.py
Normal file
399
modules/routes/billingWebhookHandler.py
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Stripe webhook and subscription business logic for billing.
|
||||||
|
Extracted from routeBilling.py for maintainability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBilling import (
|
||||||
|
BillingTransaction,
|
||||||
|
TransactionTypeEnum,
|
||||||
|
ReferenceTypeEnum,
|
||||||
|
)
|
||||||
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
|
routeApiMsg = apiRouteContext("routeBilling")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def creditStripeSessionIfNeeded(
|
||||||
|
billingInterface,
|
||||||
|
session: Dict[str, Any],
|
||||||
|
eventId: Optional[str] = None,
|
||||||
|
CheckoutConfirmResponse=None,
|
||||||
|
):
|
||||||
|
"""Credit balance from Stripe Checkout session if not already credited.
|
||||||
|
Uses Checkout session ID for idempotency across webhook + manual confirmation flows.
|
||||||
|
"""
|
||||||
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF
|
||||||
|
|
||||||
|
session_id = session.get("id")
|
||||||
|
metadata = session.get("metadata") or {}
|
||||||
|
mandate_id = metadata.get("mandateId")
|
||||||
|
user_id = metadata.get("userId") or None
|
||||||
|
amount_chf_str = metadata.get("amountChf", "0")
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("Stripe session id missing"))
|
||||||
|
if not mandate_id:
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing"))
|
||||||
|
|
||||||
|
existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id)
|
||||||
|
if existing_payment_tx:
|
||||||
|
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
||||||
|
billingInterface.createStripeWebhookEvent(eventId)
|
||||||
|
return CheckoutConfirmResponse(
|
||||||
|
credited=False,
|
||||||
|
alreadyCredited=True,
|
||||||
|
sessionId=session_id,
|
||||||
|
mandateId=mandate_id,
|
||||||
|
amountChf=float(existing_payment_tx.get("amount", 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount_chf = float(amount_chf_str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
amount_chf = None
|
||||||
|
|
||||||
|
if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF:
|
||||||
|
amount_total = session.get("amount_total")
|
||||||
|
if amount_total is not None:
|
||||||
|
amount_chf = amount_total / 100.0
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid amount in Stripe session"))
|
||||||
|
|
||||||
|
settings = billingInterface.getSettings(mandate_id)
|
||||||
|
if not settings:
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found"))
|
||||||
|
|
||||||
|
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
||||||
|
|
||||||
|
transaction = BillingTransaction(
|
||||||
|
accountId=account["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=amount_chf,
|
||||||
|
description="Stripe-Zahlung",
|
||||||
|
referenceType=ReferenceTypeEnum.PAYMENT,
|
||||||
|
referenceId=session_id,
|
||||||
|
createdByUserId=user_id,
|
||||||
|
)
|
||||||
|
billingInterface.createTransaction(transaction)
|
||||||
|
|
||||||
|
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
||||||
|
billingInterface.createStripeWebhookEvent(eventId)
|
||||||
|
|
||||||
|
logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}")
|
||||||
|
return CheckoutConfirmResponse(
|
||||||
|
credited=True,
|
||||||
|
alreadyCredited=False,
|
||||||
|
sessionId=session_id,
|
||||||
|
mandateId=mandate_id,
|
||||||
|
amountChf=amount_chf,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handleSubscriptionCheckoutCompleted(session, eventId: str, getRootInterface) -> None:
|
||||||
|
"""Handle checkout.session.completed for mode=subscription.
|
||||||
|
Resolves the local PENDING record by ID from webhook metadata and transitions it."""
|
||||||
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
||||||
|
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||||
|
getService as getSubscriptionService,
|
||||||
|
_notifySubscriptionChange,
|
||||||
|
)
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
from modules.shared.stripeClient import stripeToDict
|
||||||
|
session = stripeToDict(session)
|
||||||
|
|
||||||
|
metadata = session.get("metadata") or {}
|
||||||
|
subscriptionRecordId = metadata.get("subscriptionRecordId")
|
||||||
|
mandateId = metadata.get("mandateId")
|
||||||
|
planKey = metadata.get("planKey", "")
|
||||||
|
platformUrl = metadata.get("platformUrl", "")
|
||||||
|
|
||||||
|
if not subscriptionRecordId:
|
||||||
|
stripeSub = session.get("subscription")
|
||||||
|
if stripeSub:
|
||||||
|
try:
|
||||||
|
from modules.shared.stripeClient import getStripeClient
|
||||||
|
stripe = getStripeClient()
|
||||||
|
from modules.shared.stripeClient import stripeToDict
|
||||||
|
subObj = stripeToDict(stripe.Subscription.retrieve(stripeSub))
|
||||||
|
metadata = subObj.get("metadata") or {}
|
||||||
|
subscriptionRecordId = metadata.get("subscriptionRecordId")
|
||||||
|
mandateId = metadata.get("mandateId")
|
||||||
|
planKey = metadata.get("planKey", "")
|
||||||
|
platformUrl = platformUrl or metadata.get("platformUrl", "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Stripe Subscription.retrieve(%s) failed during checkout "
|
||||||
|
"metadata recovery: %s", stripeSub, e,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
stripeSubId = session.get("subscription")
|
||||||
|
|
||||||
|
if not mandateId or not subscriptionRecordId:
|
||||||
|
logger.warning("Subscription checkout missing metadata: %s", metadata)
|
||||||
|
return
|
||||||
|
|
||||||
|
subInterface = getSubRootInterface()
|
||||||
|
rootUser = getRootUser()
|
||||||
|
|
||||||
|
sub = subInterface.getById(subscriptionRecordId)
|
||||||
|
if not sub:
|
||||||
|
logger.error("Subscription record %s not found for checkout webhook", subscriptionRecordId)
|
||||||
|
return
|
||||||
|
if sub.get("status") != SubscriptionStatusEnum.PENDING.value:
|
||||||
|
logger.warning("Subscription %s is %s, expected PENDING — skipping", subscriptionRecordId, sub.get("status"))
|
||||||
|
return
|
||||||
|
|
||||||
|
stripeData: Dict[str, Any] = {}
|
||||||
|
if stripeSubId:
|
||||||
|
stripeData["stripeSubscriptionId"] = stripeSubId
|
||||||
|
try:
|
||||||
|
from modules.shared.stripeClient import getStripeClient
|
||||||
|
stripe = getStripeClient()
|
||||||
|
from modules.shared.stripeClient import stripeToDict
|
||||||
|
stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId, expand=["items"]))
|
||||||
|
|
||||||
|
if stripeSub.get("current_period_start"):
|
||||||
|
stripeData["currentPeriodStart"] = float(stripeSub["current_period_start"])
|
||||||
|
if stripeSub.get("current_period_end"):
|
||||||
|
stripeData["currentPeriodEnd"] = float(stripeSub["current_period_end"])
|
||||||
|
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import getStripePricesForPlan
|
||||||
|
priceMapping = getStripePricesForPlan(planKey)
|
||||||
|
items = stripeSub.get("items") or {}
|
||||||
|
if not isinstance(items, dict):
|
||||||
|
items = dict(items)
|
||||||
|
for item in items.get("data", []):
|
||||||
|
priceId = (item.get("price") or {}).get("id", "")
|
||||||
|
if priceMapping and priceId == priceMapping.stripePriceIdUsers:
|
||||||
|
stripeData["stripeItemIdUsers"] = item["id"]
|
||||||
|
elif priceMapping and priceId == priceMapping.stripePriceIdInstances:
|
||||||
|
stripeData["stripeItemIdInstances"] = item["id"]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error retrieving Stripe subscription %s during checkout "
|
||||||
|
"completion (will be retried by Stripe): %s",
|
||||||
|
stripeSubId, e,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if stripeData:
|
||||||
|
subInterface.updateFields(subscriptionRecordId, stripeData)
|
||||||
|
|
||||||
|
operative = subInterface.getOperativeForMandate(mandateId)
|
||||||
|
hasActivePredecessor = operative is not None and operative["id"] != subscriptionRecordId
|
||||||
|
predecessorIsTrial = (
|
||||||
|
hasActivePredecessor
|
||||||
|
and operative.get("status") == SubscriptionStatusEnum.TRIALING.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasActivePredecessor and predecessorIsTrial:
|
||||||
|
try:
|
||||||
|
subInterface.forceExpire(operative["id"])
|
||||||
|
logger.info(
|
||||||
|
"Trial subscription %s expired immediately for mandate %s due to paid upgrade %s",
|
||||||
|
operative["id"], mandateId, subscriptionRecordId,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to expire trial predecessor %s: %s", operative["id"], e)
|
||||||
|
toStatus = SubscriptionStatusEnum.ACTIVE
|
||||||
|
elif hasActivePredecessor:
|
||||||
|
toStatus = SubscriptionStatusEnum.SCHEDULED
|
||||||
|
if operative.get("recurring", True):
|
||||||
|
operativeStripeId = operative.get("stripeSubscriptionId")
|
||||||
|
if operativeStripeId:
|
||||||
|
try:
|
||||||
|
from modules.shared.stripeClient import getStripeClient
|
||||||
|
stripe = getStripeClient()
|
||||||
|
stripe.Subscription.modify(operativeStripeId, cancel_at_period_end=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set cancel_at_period_end on predecessor %s: %s", operativeStripeId, e)
|
||||||
|
subInterface.updateFields(operative["id"], {"recurring": False})
|
||||||
|
effectiveFrom = operative.get("currentPeriodEnd")
|
||||||
|
if effectiveFrom:
|
||||||
|
subInterface.updateFields(subscriptionRecordId, {"effectiveFrom": effectiveFrom})
|
||||||
|
else:
|
||||||
|
toStatus = SubscriptionStatusEnum.ACTIVE
|
||||||
|
|
||||||
|
try:
|
||||||
|
subInterface.transitionStatus(
|
||||||
|
subscriptionRecordId, SubscriptionStatusEnum.PENDING, toStatus,
|
||||||
|
{"recurring": True},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to transition subscription %s: %s", subscriptionRecordId, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
subService = getSubscriptionService(rootUser, mandateId)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
|
||||||
|
if toStatus == SubscriptionStatusEnum.ACTIVE:
|
||||||
|
plan = getPlan(planKey)
|
||||||
|
updatedSub = subInterface.getById(subscriptionRecordId)
|
||||||
|
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl)
|
||||||
|
|
||||||
|
try:
|
||||||
|
billingIf = getRootInterface()
|
||||||
|
billingIf.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget on activation failed: %s", ex)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Checkout completed: sub=%s -> %s, mandate=%s, plan=%s",
|
||||||
|
subscriptionRecordId, toStatus.value, mandateId, planKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handleSubscriptionWebhook(event, getRootInterface) -> None:
|
||||||
|
"""Process Stripe subscription webhook events.
|
||||||
|
All record resolution is by stripeSubscriptionId."""
|
||||||
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
||||||
|
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||||
|
getService as getSubscriptionService,
|
||||||
|
_notifySubscriptionChange,
|
||||||
|
)
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
|
||||||
|
obj = event.data.object
|
||||||
|
rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
||||||
|
stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub
|
||||||
|
if not stripeSubId:
|
||||||
|
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
||||||
|
return
|
||||||
|
|
||||||
|
subInterface = getSubRootInterface()
|
||||||
|
sub = subInterface.getByStripeSubscriptionId(stripeSubId)
|
||||||
|
if not sub:
|
||||||
|
logger.warning("No local record for Stripe subscription %s (event: %s)", stripeSubId, event.type)
|
||||||
|
return
|
||||||
|
|
||||||
|
subId = sub["id"]
|
||||||
|
mandateId = sub["mandateId"]
|
||||||
|
currentStatus = SubscriptionStatusEnum(sub["status"])
|
||||||
|
rootUser = getRootUser()
|
||||||
|
subService = getSubscriptionService(rootUser, mandateId)
|
||||||
|
|
||||||
|
subMetadata = obj.get("metadata") or {}
|
||||||
|
webhookPlatformUrl = subMetadata.get("platformUrl", "")
|
||||||
|
|
||||||
|
if event.type == "customer.subscription.updated":
|
||||||
|
stripeStatus = obj.get("status", "")
|
||||||
|
|
||||||
|
periodData: Dict[str, Any] = {}
|
||||||
|
if obj.get("current_period_start"):
|
||||||
|
periodData["currentPeriodStart"] = float(obj["current_period_start"])
|
||||||
|
if obj.get("current_period_end"):
|
||||||
|
periodData["currentPeriodEnd"] = float(obj["current_period_end"])
|
||||||
|
if periodData:
|
||||||
|
subInterface.updateFields(subId, periodData)
|
||||||
|
|
||||||
|
if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED:
|
||||||
|
subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
planKey = sub.get("planKey", "")
|
||||||
|
plan = getPlan(planKey)
|
||||||
|
refreshedSub = subInterface.getById(subId)
|
||||||
|
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl)
|
||||||
|
try:
|
||||||
|
getRootInterface().creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget SCHEDULED->ACTIVE failed: %s", ex)
|
||||||
|
logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE:
|
||||||
|
subInterface.transitionStatus(subId, SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.ACTIVE)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
logger.info("PAST_DUE -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
elif stripeStatus == "past_due" and currentStatus == SubscriptionStatusEnum.ACTIVE:
|
||||||
|
subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
logger.info("ACTIVE -> PAST_DUE for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.ACTIVE:
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
logger.info("Period renewed for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
elif event.type == "customer.subscription.deleted":
|
||||||
|
if currentStatus not in (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE,
|
||||||
|
SubscriptionStatusEnum.SCHEDULED):
|
||||||
|
logger.info("Ignoring deletion for sub %s in status %s", subId, currentStatus.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
subInterface.transitionStatus(subId, currentStatus, SubscriptionStatusEnum.EXPIRED)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
logger.info("Sub %s -> EXPIRED (Stripe deleted, mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
scheduled = subInterface.getScheduledForMandate(mandateId)
|
||||||
|
if scheduled:
|
||||||
|
try:
|
||||||
|
subInterface.transitionStatus(
|
||||||
|
scheduled["id"], SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE,
|
||||||
|
)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
plan = getPlan(scheduled.get("planKey", ""))
|
||||||
|
refreshedScheduled = subInterface.getById(scheduled["id"])
|
||||||
|
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedScheduled, platformUrl=webhookPlatformUrl)
|
||||||
|
logger.info("Promoted SCHEDULED sub %s -> ACTIVE (mandate %s)", scheduled["id"], mandateId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to promote SCHEDULED sub %s: %s", scheduled["id"], e)
|
||||||
|
|
||||||
|
elif event.type == "invoice.payment_failed":
|
||||||
|
if currentStatus == SubscriptionStatusEnum.ACTIVE:
|
||||||
|
subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE)
|
||||||
|
subService.invalidateCache(mandateId)
|
||||||
|
plan = getPlan(sub.get("planKey", ""))
|
||||||
|
_notifySubscriptionChange(mandateId, "payment_failed", plan, subscriptionRecord=sub, platformUrl=webhookPlatformUrl)
|
||||||
|
logger.info("Payment failed for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
|
elif event.type == "customer.subscription.trial_will_end":
|
||||||
|
logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
try:
|
||||||
|
from modules.system.notifyMandateAdmins import notifyMandateAdmins
|
||||||
|
notifyMandateAdmins(
|
||||||
|
mandateId,
|
||||||
|
"[PowerOn] Testphase endet bald",
|
||||||
|
"Testphase endet bald",
|
||||||
|
[
|
||||||
|
"Die kostenlose Testphase für Ihren Mandanten endet in Kürze.",
|
||||||
|
"Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to notify about trial ending: %s", e)
|
||||||
|
|
||||||
|
elif event.type == "invoice.paid":
|
||||||
|
period_ts = obj.get("period_start")
|
||||||
|
periodLabel = ""
|
||||||
|
if period_ts:
|
||||||
|
period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc)
|
||||||
|
periodLabel = period_start_at.strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
billing_if = getRootInterface()
|
||||||
|
billing_if.resetStorageBillingPeriod(mandateId, period_start_at)
|
||||||
|
billing_if.reconcileMandateStorageBilling(mandateId)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("Storage billing on invoice.paid failed: %s", ex)
|
||||||
|
|
||||||
|
planKey = sub.get("planKey", "")
|
||||||
|
try:
|
||||||
|
billing_if = getRootInterface()
|
||||||
|
billing_if.creditSubscriptionBudget(mandateId, planKey, periodLabel=periodLabel or "Periodenverlängerung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget on invoice.paid failed: %s", ex)
|
||||||
|
|
||||||
|
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
return None
|
||||||
|
|
@ -330,80 +330,10 @@ def _getStripeClient():
|
||||||
return getStripeClient()
|
return getStripeClient()
|
||||||
|
|
||||||
|
|
||||||
def _creditStripeSessionIfNeeded(
|
def _creditStripeSessionIfNeeded(billingInterface, session: Dict[str, Any], eventId: Optional[str] = None) -> CheckoutConfirmResponse:
|
||||||
billingInterface,
|
"""Credit balance from Stripe Checkout session if not already credited."""
|
||||||
session: Dict[str, Any],
|
from .billingWebhookHandler import creditStripeSessionIfNeeded
|
||||||
eventId: Optional[str] = None,
|
return creditStripeSessionIfNeeded(billingInterface, session, eventId, CheckoutConfirmResponse)
|
||||||
) -> CheckoutConfirmResponse:
|
|
||||||
"""
|
|
||||||
Credit balance from Stripe Checkout session if not already credited.
|
|
||||||
Uses Checkout session ID for idempotency across webhook + manual confirmation flows.
|
|
||||||
"""
|
|
||||||
from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF
|
|
||||||
|
|
||||||
session_id = session.get("id")
|
|
||||||
metadata = session.get("metadata") or {}
|
|
||||||
mandate_id = metadata.get("mandateId")
|
|
||||||
user_id = metadata.get("userId") or None
|
|
||||||
amount_chf_str = metadata.get("amountChf", "0")
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Stripe session id missing"))
|
|
||||||
if not mandate_id:
|
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing"))
|
|
||||||
|
|
||||||
existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id)
|
|
||||||
if existing_payment_tx:
|
|
||||||
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
|
||||||
billingInterface.createStripeWebhookEvent(eventId)
|
|
||||||
return CheckoutConfirmResponse(
|
|
||||||
credited=False,
|
|
||||||
alreadyCredited=True,
|
|
||||||
sessionId=session_id,
|
|
||||||
mandateId=mandate_id,
|
|
||||||
amountChf=float(existing_payment_tx.get("amount", 0.0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
amount_chf = float(amount_chf_str)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
amount_chf = None
|
|
||||||
|
|
||||||
if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF:
|
|
||||||
amount_total = session.get("amount_total")
|
|
||||||
if amount_total is not None:
|
|
||||||
amount_chf = amount_total / 100.0
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Invalid amount in Stripe session"))
|
|
||||||
|
|
||||||
settings = billingInterface.getSettings(mandate_id)
|
|
||||||
if not settings:
|
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found"))
|
|
||||||
|
|
||||||
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
|
||||||
|
|
||||||
transaction = BillingTransaction(
|
|
||||||
accountId=account["id"],
|
|
||||||
transactionType=TransactionTypeEnum.CREDIT,
|
|
||||||
amount=amount_chf,
|
|
||||||
description="Stripe-Zahlung",
|
|
||||||
referenceType=ReferenceTypeEnum.PAYMENT,
|
|
||||||
referenceId=session_id,
|
|
||||||
createdByUserId=user_id,
|
|
||||||
)
|
|
||||||
billingInterface.createTransaction(transaction)
|
|
||||||
|
|
||||||
if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId):
|
|
||||||
billingInterface.createStripeWebhookEvent(eventId)
|
|
||||||
|
|
||||||
logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}")
|
|
||||||
return CheckoutConfirmResponse(
|
|
||||||
credited=True,
|
|
||||||
alreadyCredited=False,
|
|
||||||
sessionId=session_id,
|
|
||||||
mandateId=mandate_id,
|
|
||||||
amountChf=amount_chf,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -1148,314 +1078,15 @@ async def stripeWebhook(
|
||||||
|
|
||||||
|
|
||||||
def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
|
def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
|
||||||
"""Handle checkout.session.completed for mode=subscription.
|
"""Handle checkout.session.completed for mode=subscription."""
|
||||||
Resolves the local PENDING record by ID from webhook metadata and transitions it."""
|
from .billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
_handler(session, eventId, getRootInterface)
|
||||||
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan
|
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
|
||||||
getService as getSubscriptionService,
|
|
||||||
_notifySubscriptionChange,
|
|
||||||
)
|
|
||||||
from modules.security.rootAccess import getRootUser
|
|
||||||
|
|
||||||
if not isinstance(session, dict):
|
|
||||||
from modules.shared.stripeClient import stripeToDict
|
|
||||||
session = stripeToDict(session)
|
|
||||||
|
|
||||||
metadata = session.get("metadata") or {}
|
|
||||||
subscriptionRecordId = metadata.get("subscriptionRecordId")
|
|
||||||
mandateId = metadata.get("mandateId")
|
|
||||||
planKey = metadata.get("planKey", "")
|
|
||||||
|
|
||||||
platformUrl = metadata.get("platformUrl", "")
|
|
||||||
|
|
||||||
if not subscriptionRecordId:
|
|
||||||
stripeSub = session.get("subscription")
|
|
||||||
if stripeSub:
|
|
||||||
try:
|
|
||||||
from modules.shared.stripeClient import getStripeClient
|
|
||||||
stripe = getStripeClient()
|
|
||||||
from modules.shared.stripeClient import stripeToDict
|
|
||||||
subObj = stripeToDict(stripe.Subscription.retrieve(stripeSub))
|
|
||||||
metadata = subObj.get("metadata") or {}
|
|
||||||
subscriptionRecordId = metadata.get("subscriptionRecordId")
|
|
||||||
mandateId = metadata.get("mandateId")
|
|
||||||
planKey = metadata.get("planKey", "")
|
|
||||||
platformUrl = platformUrl or metadata.get("platformUrl", "")
|
|
||||||
except Exception as e:
|
|
||||||
# Stripe lookup is the only way to recover the metadata at this
|
|
||||||
# point — if it fails we MUST surface it, otherwise the webhook
|
|
||||||
# later short-circuits with "missing metadata" and the user
|
|
||||||
# silently gets stuck in PENDING.
|
|
||||||
logger.error(
|
|
||||||
"Stripe Subscription.retrieve(%s) failed during checkout "
|
|
||||||
"metadata recovery: %s", stripeSub, e,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
stripeSubId = session.get("subscription")
|
|
||||||
|
|
||||||
if not mandateId or not subscriptionRecordId:
|
|
||||||
logger.warning("Subscription checkout missing metadata: %s", metadata)
|
|
||||||
return
|
|
||||||
|
|
||||||
subInterface = getSubRootInterface()
|
|
||||||
rootUser = getRootUser()
|
|
||||||
|
|
||||||
sub = subInterface.getById(subscriptionRecordId)
|
|
||||||
if not sub:
|
|
||||||
logger.error("Subscription record %s not found for checkout webhook", subscriptionRecordId)
|
|
||||||
return
|
|
||||||
if sub.get("status") != SubscriptionStatusEnum.PENDING.value:
|
|
||||||
logger.warning("Subscription %s is %s, expected PENDING — skipping", subscriptionRecordId, sub.get("status"))
|
|
||||||
return
|
|
||||||
|
|
||||||
stripeData: Dict[str, Any] = {}
|
|
||||||
if stripeSubId:
|
|
||||||
stripeData["stripeSubscriptionId"] = stripeSubId
|
|
||||||
try:
|
|
||||||
from modules.shared.stripeClient import getStripeClient
|
|
||||||
stripe = getStripeClient()
|
|
||||||
from modules.shared.stripeClient import stripeToDict
|
|
||||||
stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId, expand=["items"]))
|
|
||||||
|
|
||||||
if stripeSub.get("current_period_start"):
|
|
||||||
stripeData["currentPeriodStart"] = float(stripeSub["current_period_start"])
|
|
||||||
if stripeSub.get("current_period_end"):
|
|
||||||
stripeData["currentPeriodEnd"] = float(stripeSub["current_period_end"])
|
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import getStripePricesForPlan
|
|
||||||
priceMapping = getStripePricesForPlan(planKey)
|
|
||||||
items = stripeSub.get("items") or {}
|
|
||||||
if not isinstance(items, dict):
|
|
||||||
items = dict(items)
|
|
||||||
for item in items.get("data", []):
|
|
||||||
priceId = (item.get("price") or {}).get("id", "")
|
|
||||||
if priceMapping and priceId == priceMapping.stripePriceIdUsers:
|
|
||||||
stripeData["stripeItemIdUsers"] = item["id"]
|
|
||||||
elif priceMapping and priceId == priceMapping.stripePriceIdInstances:
|
|
||||||
stripeData["stripeItemIdInstances"] = item["id"]
|
|
||||||
except Exception as e:
|
|
||||||
# Without these enrichment fields the activation completes anyway
|
|
||||||
# (status flips to ACTIVE/SCHEDULED below), but periods + Stripe
|
|
||||||
# item-IDs are missing on the local record, which breaks later
|
|
||||||
# add-on billing and renewal accounting. Re-raise so the webhook
|
|
||||||
# is retried by Stripe instead of silently shipping a broken row.
|
|
||||||
logger.error(
|
|
||||||
"Error retrieving Stripe subscription %s during checkout "
|
|
||||||
"completion (will be retried by Stripe): %s",
|
|
||||||
stripeSubId, e,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
if stripeData:
|
|
||||||
subInterface.updateFields(subscriptionRecordId, stripeData)
|
|
||||||
|
|
||||||
operative = subInterface.getOperativeForMandate(mandateId)
|
|
||||||
hasActivePredecessor = operative is not None and operative["id"] != subscriptionRecordId
|
|
||||||
predecessorIsTrial = (
|
|
||||||
hasActivePredecessor
|
|
||||||
and operative.get("status") == SubscriptionStatusEnum.TRIALING.value
|
|
||||||
)
|
|
||||||
|
|
||||||
if hasActivePredecessor and predecessorIsTrial:
|
|
||||||
try:
|
|
||||||
subInterface.forceExpire(operative["id"])
|
|
||||||
logger.info(
|
|
||||||
"Trial subscription %s expired immediately for mandate %s due to paid upgrade %s",
|
|
||||||
operative["id"], mandateId, subscriptionRecordId,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to expire trial predecessor %s: %s", operative["id"], e)
|
|
||||||
toStatus = SubscriptionStatusEnum.ACTIVE
|
|
||||||
elif hasActivePredecessor:
|
|
||||||
toStatus = SubscriptionStatusEnum.SCHEDULED
|
|
||||||
if operative.get("recurring", True):
|
|
||||||
operativeStripeId = operative.get("stripeSubscriptionId")
|
|
||||||
if operativeStripeId:
|
|
||||||
try:
|
|
||||||
from modules.shared.stripeClient import getStripeClient
|
|
||||||
stripe = getStripeClient()
|
|
||||||
stripe.Subscription.modify(operativeStripeId, cancel_at_period_end=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to set cancel_at_period_end on predecessor %s: %s", operativeStripeId, e)
|
|
||||||
subInterface.updateFields(operative["id"], {"recurring": False})
|
|
||||||
effectiveFrom = operative.get("currentPeriodEnd")
|
|
||||||
if effectiveFrom:
|
|
||||||
subInterface.updateFields(subscriptionRecordId, {"effectiveFrom": effectiveFrom})
|
|
||||||
else:
|
|
||||||
toStatus = SubscriptionStatusEnum.ACTIVE
|
|
||||||
|
|
||||||
try:
|
|
||||||
subInterface.transitionStatus(
|
|
||||||
subscriptionRecordId, SubscriptionStatusEnum.PENDING, toStatus,
|
|
||||||
{"recurring": True},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to transition subscription %s: %s", subscriptionRecordId, e)
|
|
||||||
return
|
|
||||||
|
|
||||||
subService = getSubscriptionService(rootUser, mandateId)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
|
|
||||||
if toStatus == SubscriptionStatusEnum.ACTIVE:
|
|
||||||
plan = getPlan(planKey)
|
|
||||||
updatedSub = subInterface.getById(subscriptionRecordId)
|
|
||||||
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl)
|
|
||||||
|
|
||||||
try:
|
|
||||||
billingIf = getRootInterface()
|
|
||||||
billingIf.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error("creditSubscriptionBudget on activation failed: %s", ex)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Checkout completed: sub=%s -> %s, mandate=%s, plan=%s",
|
|
||||||
subscriptionRecordId, toStatus.value, mandateId, planKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handleSubscriptionWebhook(event) -> None:
|
def _handleSubscriptionWebhook(event) -> None:
|
||||||
"""Process Stripe subscription webhook events.
|
"""Process Stripe subscription webhook events."""
|
||||||
All record resolution is by stripeSubscriptionId — no mandate-based guessing."""
|
from .billingWebhookHandler import handleSubscriptionWebhook as _handler
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
_handler(event, getRootInterface)
|
||||||
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan
|
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
|
||||||
getService as getSubscriptionService,
|
|
||||||
_notifySubscriptionChange,
|
|
||||||
)
|
|
||||||
from modules.security.rootAccess import getRootUser
|
|
||||||
|
|
||||||
obj = event.data.object
|
|
||||||
rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
|
||||||
stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub
|
|
||||||
if not stripeSubId:
|
|
||||||
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
|
||||||
return
|
|
||||||
|
|
||||||
subInterface = getSubRootInterface()
|
|
||||||
sub = subInterface.getByStripeSubscriptionId(stripeSubId)
|
|
||||||
if not sub:
|
|
||||||
logger.warning("No local record for Stripe subscription %s (event: %s)", stripeSubId, event.type)
|
|
||||||
return
|
|
||||||
|
|
||||||
subId = sub["id"]
|
|
||||||
mandateId = sub["mandateId"]
|
|
||||||
currentStatus = SubscriptionStatusEnum(sub["status"])
|
|
||||||
rootUser = getRootUser()
|
|
||||||
subService = getSubscriptionService(rootUser, mandateId)
|
|
||||||
|
|
||||||
subMetadata = obj.get("metadata") or {}
|
|
||||||
webhookPlatformUrl = subMetadata.get("platformUrl", "")
|
|
||||||
|
|
||||||
if event.type == "customer.subscription.updated":
|
|
||||||
stripeStatus = obj.get("status", "")
|
|
||||||
|
|
||||||
periodData: Dict[str, Any] = {}
|
|
||||||
if obj.get("current_period_start"):
|
|
||||||
periodData["currentPeriodStart"] = float(obj["current_period_start"])
|
|
||||||
if obj.get("current_period_end"):
|
|
||||||
periodData["currentPeriodEnd"] = float(obj["current_period_end"])
|
|
||||||
if periodData:
|
|
||||||
subInterface.updateFields(subId, periodData)
|
|
||||||
|
|
||||||
if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED:
|
|
||||||
subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
planKey = sub.get("planKey", "")
|
|
||||||
plan = getPlan(planKey)
|
|
||||||
refreshedSub = subInterface.getById(subId)
|
|
||||||
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl)
|
|
||||||
try:
|
|
||||||
getRootInterface().creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error("creditSubscriptionBudget SCHEDULED->ACTIVE failed: %s", ex)
|
|
||||||
logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE:
|
|
||||||
subInterface.transitionStatus(subId, SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.ACTIVE)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
logger.info("PAST_DUE -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
elif stripeStatus == "past_due" and currentStatus == SubscriptionStatusEnum.ACTIVE:
|
|
||||||
subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
logger.info("ACTIVE -> PAST_DUE for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.ACTIVE:
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
logger.info("Period renewed for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
elif event.type == "customer.subscription.deleted":
|
|
||||||
if currentStatus not in (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE,
|
|
||||||
SubscriptionStatusEnum.SCHEDULED):
|
|
||||||
logger.info("Ignoring deletion for sub %s in status %s", subId, currentStatus.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
subInterface.transitionStatus(subId, currentStatus, SubscriptionStatusEnum.EXPIRED)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
logger.info("Sub %s -> EXPIRED (Stripe deleted, mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
scheduled = subInterface.getScheduledForMandate(mandateId)
|
|
||||||
if scheduled:
|
|
||||||
try:
|
|
||||||
subInterface.transitionStatus(
|
|
||||||
scheduled["id"], SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE,
|
|
||||||
)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
plan = getPlan(scheduled.get("planKey", ""))
|
|
||||||
refreshedScheduled = subInterface.getById(scheduled["id"])
|
|
||||||
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedScheduled, platformUrl=webhookPlatformUrl)
|
|
||||||
logger.info("Promoted SCHEDULED sub %s -> ACTIVE (mandate %s)", scheduled["id"], mandateId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to promote SCHEDULED sub %s: %s", scheduled["id"], e)
|
|
||||||
|
|
||||||
elif event.type == "invoice.payment_failed":
|
|
||||||
if currentStatus == SubscriptionStatusEnum.ACTIVE:
|
|
||||||
subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE)
|
|
||||||
subService.invalidateCache(mandateId)
|
|
||||||
plan = getPlan(sub.get("planKey", ""))
|
|
||||||
_notifySubscriptionChange(mandateId, "payment_failed", plan, subscriptionRecord=sub, platformUrl=webhookPlatformUrl)
|
|
||||||
logger.info("Payment failed for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
|
|
||||||
elif event.type == "customer.subscription.trial_will_end":
|
|
||||||
logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
try:
|
|
||||||
from modules.system.notifyMandateAdmins import notifyMandateAdmins
|
|
||||||
notifyMandateAdmins(
|
|
||||||
mandateId,
|
|
||||||
"[PowerOn] Testphase endet bald",
|
|
||||||
"Testphase endet bald",
|
|
||||||
[
|
|
||||||
"Die kostenlose Testphase für Ihren Mandanten endet in Kürze.",
|
|
||||||
"Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement.",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to notify about trial ending: %s", e)
|
|
||||||
|
|
||||||
elif event.type == "invoice.paid":
|
|
||||||
period_ts = obj.get("period_start")
|
|
||||||
periodLabel = ""
|
|
||||||
if period_ts:
|
|
||||||
period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc)
|
|
||||||
periodLabel = period_start_at.strftime("%Y-%m-%d")
|
|
||||||
try:
|
|
||||||
billing_if = getRootInterface()
|
|
||||||
billing_if.resetStorageBillingPeriod(mandateId, period_start_at)
|
|
||||||
billing_if.reconcileMandateStorageBilling(mandateId)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error("Storage billing on invoice.paid failed: %s", ex)
|
|
||||||
|
|
||||||
planKey = sub.get("planKey", "")
|
|
||||||
try:
|
|
||||||
billing_if = getRootInterface()
|
|
||||||
billing_if.creditSubscriptionBudget(mandateId, planKey, periodLabel=periodLabel or "Periodenverlängerung")
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error("creditSubscriptionBudget on invoice.paid failed: %s", ex)
|
|
||||||
|
|
||||||
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary])
|
@router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary])
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, sta
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import getCurrentUser, limiter
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
|
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
from modules.interfaces.interfaceDbApp import getInterface
|
||||||
from modules.serviceHub import getInterface as getServices
|
from modules.serviceCenter.serviceHub import getInterface as getServices
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
routeApiMsg = apiRouteContext("routeClickup")
|
routeApiMsg = apiRouteContext("routeClickup")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection
|
from modules.datamodels.datamodelUam import User, UserConnection
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
from modules.interfaces.interfaceDbApp import getInterface
|
||||||
from modules.serviceHub import getInterface as getServices
|
from modules.serviceCenter.serviceHub import getInterface as getServices
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
routeApiMsg = apiRouteContext("routeSharepoint")
|
routeApiMsg = apiRouteContext("routeSharepoint")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
Root access management for system-level operations.
|
Root access management for system-level operations.
|
||||||
Provides secure access to root user and DbApp database connector.
|
Provides secure access to root user and DbApp database connector.
|
||||||
|
|
||||||
Bei leerer Datenbank wird automatisch Bootstrap ausgeführt.
|
Bootstrap is guaranteed by app.py lifespan before any access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_rootDbAppConnector = None
|
_rootDbAppConnector = None
|
||||||
_rootUser = None
|
_rootUser = None
|
||||||
_bootstrapExecuted = False
|
|
||||||
|
|
||||||
def getRootDbAppConnector() -> DatabaseConnector:
|
def getRootDbAppConnector() -> DatabaseConnector:
|
||||||
"""
|
"""
|
||||||
|
|
@ -39,34 +39,12 @@ def getRootDbAppConnector() -> DatabaseConnector:
|
||||||
return _rootDbAppConnector
|
return _rootDbAppConnector
|
||||||
|
|
||||||
|
|
||||||
def _ensureBootstrap():
|
|
||||||
"""
|
|
||||||
Führt Bootstrap aus, falls noch nicht geschehen.
|
|
||||||
Wird automatisch aufgerufen, wenn getRootUser() keinen User findet.
|
|
||||||
"""
|
|
||||||
global _bootstrapExecuted
|
|
||||||
|
|
||||||
if _bootstrapExecuted:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Running bootstrap to initialize database")
|
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
|
||||||
from modules.interfaces.interfaceBootstrap import initBootstrap
|
|
||||||
|
|
||||||
dbApp = getRootDbAppConnector()
|
|
||||||
initBootstrap(dbApp)
|
|
||||||
|
|
||||||
_bootstrapExecuted = True
|
|
||||||
logger.info("Bootstrap completed")
|
|
||||||
|
|
||||||
|
|
||||||
def getRootUser() -> User:
|
def getRootUser() -> User:
|
||||||
"""
|
"""
|
||||||
Returns the root user (initial user from database).
|
Returns the root user (initial user from database).
|
||||||
Used for system-level operations that require root privileges.
|
Used for system-level operations that require root privileges.
|
||||||
|
|
||||||
Falls kein User existiert, wird Bootstrap automatisch ausgeführt.
|
Raises RuntimeError if no user exists (bootstrap incomplete).
|
||||||
"""
|
"""
|
||||||
global _rootUser
|
global _rootUser
|
||||||
|
|
||||||
|
|
@ -74,19 +52,15 @@ def getRootUser() -> User:
|
||||||
dbApp = getRootDbAppConnector()
|
dbApp = getRootDbAppConnector()
|
||||||
initialUserId = dbApp.getInitialId(UserInDB)
|
initialUserId = dbApp.getInitialId(UserInDB)
|
||||||
|
|
||||||
# Wenn kein User existiert, Bootstrap ausführen
|
|
||||||
if not initialUserId:
|
if not initialUserId:
|
||||||
logger.info("No initial user found, running bootstrap")
|
raise RuntimeError(
|
||||||
_ensureBootstrap()
|
"No root user found - bootstrap incomplete. "
|
||||||
|
"Ensure app.py lifespan runs initBootstrap before any service access."
|
||||||
# Nochmal versuchen nach Bootstrap
|
)
|
||||||
initialUserId = dbApp.getInitialId(UserInDB)
|
|
||||||
if not initialUserId:
|
|
||||||
raise ValueError("No initial user ID found in database after bootstrap")
|
|
||||||
|
|
||||||
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
|
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
|
||||||
if not users:
|
if not users:
|
||||||
raise ValueError("Initial user not found in database")
|
raise RuntimeError("Initial user not found in database")
|
||||||
|
|
||||||
user_data = users[0]
|
user_data = users[0]
|
||||||
_rootUser = User(**user_data)
|
_rootUser = User(**user_data)
|
||||||
|
|
|
||||||
|
|
@ -1,222 +1,8 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""Re-export shim — canonical source is modules.shared.eventManager."""
|
||||||
Event manager for SSE streaming.
|
|
||||||
Manages event queues for Server-Sent Events (SSE) streaming across features.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
from modules.shared.eventManager import ( # noqa: F401
|
||||||
import asyncio
|
EventManager,
|
||||||
from typing import Dict, Optional, Any
|
get_event_manager,
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EventManager:
|
|
||||||
"""
|
|
||||||
Manages event queues for SSE streaming.
|
|
||||||
Each workflow has its own async queue for events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize the event manager."""
|
|
||||||
self._queues: Dict[str, asyncio.Queue] = {}
|
|
||||||
self._cleanup_tasks: Dict[str, asyncio.Task] = {}
|
|
||||||
self._agent_tasks: Dict[str, asyncio.Task] = {}
|
|
||||||
self._cancelled: Dict[str, bool] = {}
|
|
||||||
|
|
||||||
def create_queue(self, workflow_id: str) -> asyncio.Queue:
|
|
||||||
"""
|
|
||||||
Create an event queue for a workflow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workflow_id: Workflow ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Async queue for events
|
|
||||||
"""
|
|
||||||
if workflow_id in self._cleanup_tasks:
|
|
||||||
self._cleanup_tasks[workflow_id].cancel()
|
|
||||||
del self._cleanup_tasks[workflow_id]
|
|
||||||
logger.debug(f"Cancelled pending cleanup for workflow {workflow_id}")
|
|
||||||
|
|
||||||
if workflow_id not in self._queues:
|
|
||||||
self._queues[workflow_id] = asyncio.Queue()
|
|
||||||
logger.debug(f"Created event queue for workflow {workflow_id}")
|
|
||||||
else:
|
|
||||||
old = self._queues[workflow_id]
|
|
||||||
while not old.empty():
|
|
||||||
try:
|
|
||||||
old.get_nowait()
|
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
logger.debug(f"Reusing event queue for workflow {workflow_id} (drained stale events)")
|
|
||||||
return self._queues[workflow_id]
|
|
||||||
|
|
||||||
def get_queue(self, workflow_id: str) -> Optional[asyncio.Queue]:
|
|
||||||
"""
|
|
||||||
Get the event queue for a workflow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workflow_id: Workflow ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Async queue if exists, None otherwise
|
|
||||||
"""
|
|
||||||
return self._queues.get(workflow_id)
|
|
||||||
|
|
||||||
def has_queue(self, workflow_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a queue exists for a workflow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workflow_id: Workflow ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if queue exists, False otherwise
|
|
||||||
"""
|
|
||||||
return workflow_id in self._queues
|
|
||||||
|
|
||||||
def register_agent_task(self, workflow_id: str, task: asyncio.Task) -> None:
|
|
||||||
"""Register the asyncio Task running the agent for a workflow."""
|
|
||||||
self._agent_tasks[workflow_id] = task
|
|
||||||
self._cancelled.pop(workflow_id, None)
|
|
||||||
|
|
||||||
def is_cancelled(self, workflow_id: str) -> bool:
|
|
||||||
"""Check if a workflow has been cancelled."""
|
|
||||||
return self._cancelled.get(workflow_id, False)
|
|
||||||
|
|
||||||
async def cancel_agent(self, workflow_id: str) -> bool:
|
|
||||||
"""Cancel the running agent task for a workflow. Returns True if cancelled."""
|
|
||||||
self._cancelled[workflow_id] = True
|
|
||||||
task = self._agent_tasks.pop(workflow_id, None)
|
|
||||||
if task and not task.done():
|
|
||||||
task.cancel()
|
|
||||||
logger.info(f"Cancelled agent task for workflow {workflow_id}")
|
|
||||||
return True
|
|
||||||
logger.debug(f"No running agent task found for workflow {workflow_id}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _unregister_agent_task(self, workflow_id: str) -> None:
|
|
||||||
"""Remove the agent task reference after completion."""
|
|
||||||
self._agent_tasks.pop(workflow_id, None)
|
|
||||||
self._cancelled.pop(workflow_id, None)
|
|
||||||
|
|
||||||
async def emit_event(
|
|
||||||
self,
|
|
||||||
context_id: str,
|
|
||||||
event_type: str,
|
|
||||||
data: Dict[str, Any],
|
|
||||||
event_category: str = "chat",
|
|
||||||
message: Optional[str] = None,
|
|
||||||
step: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Emit an event to the queue for a workflow.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context_id: Workflow ID (context)
|
|
||||||
event_type: Type of event (e.g., "chatdata", "complete", "error")
|
|
||||||
data: Event data dictionary
|
|
||||||
event_category: Category of event (e.g., "chat", "workflow")
|
|
||||||
message: Optional message string
|
|
||||||
step: Optional step identifier
|
|
||||||
"""
|
|
||||||
queue = self._queues.get(context_id)
|
|
||||||
if not queue:
|
|
||||||
# DEBUG level: This is normal for background workflows without active SSE listener
|
|
||||||
return
|
|
||||||
|
|
||||||
event = {
|
|
||||||
"type": event_type,
|
|
||||||
"data": data,
|
|
||||||
"category": event_category,
|
|
||||||
"message": message,
|
|
||||||
"step": step
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
await queue.put(event)
|
|
||||||
if event_type not in ("chunk",):
|
|
||||||
logger.debug(f"Emitted {event_type} event for workflow {context_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error emitting event for workflow {context_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
async def cleanup(self, workflow_id: str, delay: float = 60.0) -> None:
|
|
||||||
"""
|
|
||||||
Schedule cleanup of a queue after a delay.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workflow_id: Workflow ID
|
|
||||||
delay: Delay in seconds before cleanup
|
|
||||||
"""
|
|
||||||
# Cancel existing cleanup task if any
|
|
||||||
if workflow_id in self._cleanup_tasks:
|
|
||||||
self._cleanup_tasks[workflow_id].cancel()
|
|
||||||
|
|
||||||
async def _cleanup():
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
if workflow_id in self._queues:
|
|
||||||
# Drain remaining events
|
|
||||||
queue = self._queues[workflow_id]
|
|
||||||
while not queue.empty():
|
|
||||||
try:
|
|
||||||
queue.get_nowait()
|
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Remove queue
|
|
||||||
del self._queues[workflow_id]
|
|
||||||
logger.info(f"Cleaned up event queue for workflow {workflow_id}")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.debug(f"Cleanup cancelled for workflow {workflow_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during cleanup for workflow {workflow_id}: {e}", exc_info=True)
|
|
||||||
finally:
|
|
||||||
if workflow_id in self._cleanup_tasks:
|
|
||||||
del self._cleanup_tasks[workflow_id]
|
|
||||||
|
|
||||||
# Schedule cleanup
|
|
||||||
task = asyncio.create_task(_cleanup())
|
|
||||||
self._cleanup_tasks[workflow_id] = task
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
"""Cancel all pending cleanup and agent tasks for fast process exit.
|
|
||||||
|
|
||||||
Injects ``None`` sentinels into every live queue so that SSE generators
|
|
||||||
(which block on ``queue.get()``) break out of their loop immediately
|
|
||||||
instead of waiting up to the keepalive timeout.
|
|
||||||
"""
|
|
||||||
for _wfId, q in list(self._queues.items()):
|
|
||||||
try:
|
|
||||||
q.put_nowait(None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for wfId, task in list(self._cleanup_tasks.items()):
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
self._cleanup_tasks.clear()
|
|
||||||
for wfId, task in list(self._agent_tasks.items()):
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
self._agent_tasks.clear()
|
|
||||||
self._queues.clear()
|
|
||||||
logger.info("EventManager shutdown: all tasks cancelled, queues drained")
|
|
||||||
|
|
||||||
|
|
||||||
# Global event manager instance
|
|
||||||
_event_manager: Optional[EventManager] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_event_manager() -> EventManager:
|
|
||||||
"""
|
|
||||||
Get the global event manager instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EventManager instance
|
|
||||||
"""
|
|
||||||
global _event_manager
|
|
||||||
if _event_manager is None:
|
|
||||||
_event_manager = EventManager()
|
|
||||||
return _event_manager
|
|
||||||
|
|
|
||||||
189
modules/serviceCenter/serviceHub.py
Normal file
189
modules/serviceCenter/serviceHub.py
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Service Hub.
|
||||||
|
Consumer-facing aggregation layer for services, DB interfaces, and runtime state.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- serviceHub delegates service resolution to serviceCenter (DI container)
|
||||||
|
- serviceHub owns DB interface initialization and runtime state
|
||||||
|
- serviceCenter knows nothing about serviceHub (one-way dependency)
|
||||||
|
|
||||||
|
Import-Regelwerk:
|
||||||
|
- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren
|
||||||
|
- Feature-spezifische Services werden dynamisch geladen
|
||||||
|
- Shared Services werden via serviceCenter resolved
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
import glob
|
||||||
|
from typing import Any, Optional, TYPE_CHECKING
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from modules.datamodels.datamodelChat import ChatWorkflow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features")
|
||||||
|
|
||||||
|
|
||||||
|
class PublicService:
|
||||||
|
"""Lightweight proxy exposing only public callable attributes of a target."""
|
||||||
|
|
||||||
|
def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None):
|
||||||
|
self._target = target
|
||||||
|
self._functionsOnly = functionsOnly
|
||||||
|
self._nameFilter = nameFilter
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
if name.startswith('_'):
|
||||||
|
raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private")
|
||||||
|
if self._nameFilter and not self._nameFilter(name):
|
||||||
|
raise AttributeError(f"'{name}' not exposed by policy")
|
||||||
|
attr = getattr(self._target, name)
|
||||||
|
if self._functionsOnly and not callable(attr):
|
||||||
|
raise AttributeError(f"'{name}' is not a function")
|
||||||
|
return attr
|
||||||
|
|
||||||
|
def __dir__(self):
|
||||||
|
return sorted([
|
||||||
|
n for n in dir(self._target)
|
||||||
|
if not n.startswith('_')
|
||||||
|
and (not self._functionsOnly or callable(getattr(self._target, n, None)))
|
||||||
|
and (self._nameFilter(n) if self._nameFilter else True)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceHub:
|
||||||
|
"""
|
||||||
|
Consumer-facing aggregation of services, DB interfaces, and runtime state.
|
||||||
|
|
||||||
|
Services are lazy-resolved via serviceCenter on first access.
|
||||||
|
DB interfaces and runtime state are initialized eagerly.
|
||||||
|
Feature services/interfaces are discovered dynamically from features/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SERVICE_CENTER_WRAPPING = {
|
||||||
|
"ai": {"functionsOnly": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||||
|
self.user: User = user
|
||||||
|
self.workflow = workflow
|
||||||
|
self.mandateId: Optional[str] = mandateId
|
||||||
|
self.featureInstanceId: Optional[str] = featureInstanceId
|
||||||
|
self.currentUserPrompt: str = ""
|
||||||
|
self.rawUserPrompt: str = ""
|
||||||
|
|
||||||
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
self._serviceCenterContext = ServiceCenterContext(
|
||||||
|
user=user,
|
||||||
|
workflow=workflow,
|
||||||
|
mandate_id=mandateId,
|
||||||
|
feature_instance_id=featureInstanceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
self.interfaceDbApp = getAppInterface(user, mandateId=mandateId)
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
|
self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
|
||||||
|
|
||||||
|
self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
||||||
|
self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
|
|
||||||
|
self._loadFeatureInterfaces()
|
||||||
|
self._loadFeatureServices()
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
"""Lazy-resolve services via serviceCenter on first access."""
|
||||||
|
if name.startswith('_'):
|
||||||
|
raise AttributeError(name)
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter import getService
|
||||||
|
service = getService(name, self._serviceCenterContext)
|
||||||
|
wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {})
|
||||||
|
functionsOnly = wrapping.get("functionsOnly", True)
|
||||||
|
wrapped = PublicService(service, functionsOnly=functionsOnly)
|
||||||
|
setattr(self, name, wrapped)
|
||||||
|
return wrapped
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
||||||
|
|
||||||
|
def _loadFeatureInterfaces(self):
|
||||||
|
"""Dynamically load interfaces from feature containers by filename pattern."""
|
||||||
|
pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py")
|
||||||
|
for filepath in glob.glob(pattern):
|
||||||
|
try:
|
||||||
|
featureDir = os.path.basename(os.path.dirname(filepath))
|
||||||
|
filename = os.path.basename(filepath)[:-3]
|
||||||
|
|
||||||
|
modulePath = f"modules.features.{featureDir}.{filename}"
|
||||||
|
module = importlib.import_module(modulePath)
|
||||||
|
|
||||||
|
if hasattr(module, "getInterface"):
|
||||||
|
interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
|
||||||
|
attrName = filename.replace("interfaceFeature", "interfaceDb")
|
||||||
|
setattr(self, attrName, interface)
|
||||||
|
logger.debug(f"Loaded interface: {attrName} from {modulePath}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not load interface from {filepath}: {e}")
|
||||||
|
|
||||||
|
def _loadFeatureServices(self):
|
||||||
|
"""Dynamically load services from feature containers by filename pattern."""
|
||||||
|
pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py")
|
||||||
|
for filepath in glob.glob(pattern):
|
||||||
|
try:
|
||||||
|
serviceDir = os.path.basename(os.path.dirname(filepath))
|
||||||
|
featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath)))
|
||||||
|
filename = os.path.basename(filepath)[:-3]
|
||||||
|
|
||||||
|
modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}"
|
||||||
|
module = importlib.import_module(modulePath)
|
||||||
|
|
||||||
|
serviceClass = None
|
||||||
|
for attrName in dir(module):
|
||||||
|
if attrName.endswith("Service") and not attrName.startswith("_"):
|
||||||
|
cls = getattr(module, attrName)
|
||||||
|
if isinstance(cls, type):
|
||||||
|
serviceClass = cls
|
||||||
|
break
|
||||||
|
|
||||||
|
if serviceClass:
|
||||||
|
attrName = serviceDir.replace("service", "").lower()
|
||||||
|
if not attrName:
|
||||||
|
attrName = serviceDir.lower()
|
||||||
|
|
||||||
|
functionsOnly = attrName != "ai"
|
||||||
|
|
||||||
|
def _makeServiceResolver(hub):
|
||||||
|
def _resolver(depKey: str):
|
||||||
|
return getattr(hub, depKey)
|
||||||
|
return _resolver
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(serviceClass.__init__)
|
||||||
|
paramCount = len([p for p in sig.parameters if p != 'self'])
|
||||||
|
if paramCount >= 2:
|
||||||
|
serviceInstance = serviceClass(self, _makeServiceResolver(self))
|
||||||
|
else:
|
||||||
|
serviceInstance = serviceClass(self)
|
||||||
|
setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
|
||||||
|
logger.debug(f"Loaded service: {attrName} from {modulePath}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not load service from {filepath}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible alias
|
||||||
|
Services = ServiceHub
|
||||||
|
|
||||||
|
|
||||||
|
def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub:
|
||||||
|
"""Get ServiceHub instance for the given user, mandate, and feature instance context."""
|
||||||
|
return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
|
|
@ -524,7 +524,7 @@ class ProviderNotAllowedException(Exception):
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
from modules.shared.serviceExceptions import BillingContextError
|
from modules.datamodels.serviceExceptions import BillingContextError
|
||||||
|
|
||||||
# Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException
|
# Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException
|
||||||
# instead of importing from this module
|
# instead of importing from this module
|
||||||
|
|
|
||||||
|
|
@ -76,63 +76,7 @@ def enhancePlainTextWithMarkdownTables(body: str) -> str:
|
||||||
return "\n\n".join(out_parts)
|
return "\n\n".join(out_parts)
|
||||||
|
|
||||||
|
|
||||||
def parseInlineRuns(text: str) -> list:
|
from modules.shared.documentUtils import parseInlineRuns # noqa: F401 — canonical source in shared/
|
||||||
"""
|
|
||||||
Parse inline markdown formatting into a list of InlineRun dicts.
|
|
||||||
Handles: images, links, bold, italic, inline code, plain text.
|
|
||||||
Uses a regex-based tokenizer that processes tokens left-to-right.
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return [{"type": "text", "value": ""}]
|
|
||||||
|
|
||||||
# Pattern order matters: images before links, bold before italic
|
|
||||||
_TOKEN_RE = re.compile(
|
|
||||||
r'!\[(?P<imgAlt>[^\]]*)\]\((?P<imgSrc>[^)"]+)(?:\s+"(?P<imgWidth>\d+)pt")?\)' # image
|
|
||||||
r'|\[(?P<linkText>[^\]]+)\]\((?P<linkHref>[^)]+)\)' # link
|
|
||||||
r'|`(?P<code>[^`]+)`' # inline code
|
|
||||||
r'|\*\*(?P<bold>.+?)\*\*' # bold
|
|
||||||
r'|(?<!\w)\*(?P<italic1>.+?)\*(?!\w)' # italic *x*
|
|
||||||
r'|(?<!\w)_(?P<italic2>.+?)_(?!\w)' # italic _x_
|
|
||||||
)
|
|
||||||
|
|
||||||
runs = []
|
|
||||||
lastEnd = 0
|
|
||||||
|
|
||||||
for m in _TOKEN_RE.finditer(text):
|
|
||||||
# Plain text before this match
|
|
||||||
if m.start() > lastEnd:
|
|
||||||
runs.append({"type": "text", "value": text[lastEnd:m.start()]})
|
|
||||||
|
|
||||||
if m.group("imgAlt") is not None or m.group("imgSrc") is not None:
|
|
||||||
alt = (m.group("imgAlt") or "").strip() or "Image"
|
|
||||||
src = (m.group("imgSrc") or "").strip()
|
|
||||||
widthStr = m.group("imgWidth")
|
|
||||||
run = {"type": "image", "value": alt}
|
|
||||||
if src.startswith("file:"):
|
|
||||||
run["fileId"] = src[5:]
|
|
||||||
else:
|
|
||||||
run["href"] = src
|
|
||||||
if widthStr:
|
|
||||||
run["widthPt"] = int(widthStr)
|
|
||||||
runs.append(run)
|
|
||||||
elif m.group("linkText") is not None:
|
|
||||||
runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")})
|
|
||||||
elif m.group("code") is not None:
|
|
||||||
runs.append({"type": "code", "value": m.group("code")})
|
|
||||||
elif m.group("bold") is not None:
|
|
||||||
runs.append({"type": "bold", "value": m.group("bold")})
|
|
||||||
elif m.group("italic1") is not None:
|
|
||||||
runs.append({"type": "italic", "value": m.group("italic1")})
|
|
||||||
elif m.group("italic2") is not None:
|
|
||||||
runs.append({"type": "italic", "value": m.group("italic2")})
|
|
||||||
|
|
||||||
lastEnd = m.end()
|
|
||||||
|
|
||||||
# Trailing plain text
|
|
||||||
if lastEnd < len(text):
|
|
||||||
runs.append({"type": "text", "value": text[lastEnd:]})
|
|
||||||
|
|
||||||
return runs if runs else [{"type": "text", "value": text}]
|
|
||||||
|
|
||||||
|
|
||||||
def _imageRefToDict(token: str) -> Optional[Dict[str, Any]]:
|
def _imageRefToDict(token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|
|
||||||
|
|
@ -1039,7 +1039,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
|
||||||
# Exception Classes (defined in shared, re-exported here for backward compat)
|
# Exception Classes (defined in shared, re-exported here for backward compat)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from modules.shared.serviceExceptions import (
|
from modules.datamodels.serviceExceptions import (
|
||||||
SubscriptionInactiveException,
|
SubscriptionInactiveException,
|
||||||
SubscriptionCapacityException,
|
SubscriptionCapacityException,
|
||||||
SUBSCRIPTION_USER_ACTION_UPGRADE,
|
SUBSCRIPTION_USER_ACTION_UPGRADE,
|
||||||
|
|
|
||||||
|
|
@ -1,189 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Re-export shim — canonical source: modules.serviceCenter.serviceHub
|
||||||
# All rights reserved.
|
from modules.serviceCenter.serviceHub import ( # noqa: F401
|
||||||
"""
|
PublicService,
|
||||||
Service Hub.
|
ServiceHub,
|
||||||
Consumer-facing aggregation layer for services, DB interfaces, and runtime state.
|
Services,
|
||||||
|
getInterface,
|
||||||
Architecture:
|
)
|
||||||
- serviceHub delegates service resolution to serviceCenter (DI container)
|
|
||||||
- serviceHub owns DB interface initialization and runtime state
|
|
||||||
- serviceCenter knows nothing about serviceHub (one-way dependency)
|
|
||||||
|
|
||||||
Import-Regelwerk:
|
|
||||||
- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren
|
|
||||||
- Feature-spezifische Services werden dynamisch geladen
|
|
||||||
- Shared Services werden via serviceCenter resolved
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import importlib
|
|
||||||
import glob
|
|
||||||
from typing import Any, Optional, TYPE_CHECKING
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features")
|
|
||||||
|
|
||||||
|
|
||||||
class PublicService:
|
|
||||||
"""Lightweight proxy exposing only public callable attributes of a target."""
|
|
||||||
|
|
||||||
def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None):
|
|
||||||
self._target = target
|
|
||||||
self._functionsOnly = functionsOnly
|
|
||||||
self._nameFilter = nameFilter
|
|
||||||
|
|
||||||
def __getattr__(self, name: str):
|
|
||||||
if name.startswith('_'):
|
|
||||||
raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private")
|
|
||||||
if self._nameFilter and not self._nameFilter(name):
|
|
||||||
raise AttributeError(f"'{name}' not exposed by policy")
|
|
||||||
attr = getattr(self._target, name)
|
|
||||||
if self._functionsOnly and not callable(attr):
|
|
||||||
raise AttributeError(f"'{name}' is not a function")
|
|
||||||
return attr
|
|
||||||
|
|
||||||
def __dir__(self):
|
|
||||||
return sorted([
|
|
||||||
n for n in dir(self._target)
|
|
||||||
if not n.startswith('_')
|
|
||||||
and (not self._functionsOnly or callable(getattr(self._target, n, None)))
|
|
||||||
and (self._nameFilter(n) if self._nameFilter else True)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceHub:
|
|
||||||
"""
|
|
||||||
Consumer-facing aggregation of services, DB interfaces, and runtime state.
|
|
||||||
|
|
||||||
Services are lazy-resolved via serviceCenter on first access.
|
|
||||||
DB interfaces and runtime state are initialized eagerly.
|
|
||||||
Feature services/interfaces are discovered dynamically from features/.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SERVICE_CENTER_WRAPPING = {
|
|
||||||
"ai": {"functionsOnly": False},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
|
||||||
self.user: User = user
|
|
||||||
self.workflow = workflow
|
|
||||||
self.mandateId: Optional[str] = mandateId
|
|
||||||
self.featureInstanceId: Optional[str] = featureInstanceId
|
|
||||||
self.currentUserPrompt: str = ""
|
|
||||||
self.rawUserPrompt: str = ""
|
|
||||||
|
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
|
||||||
self._serviceCenterContext = ServiceCenterContext(
|
|
||||||
user=user,
|
|
||||||
workflow=workflow,
|
|
||||||
mandate_id=mandateId,
|
|
||||||
feature_instance_id=featureInstanceId,
|
|
||||||
)
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
||||||
self.interfaceDbApp = getAppInterface(user, mandateId=mandateId)
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
|
||||||
self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
|
|
||||||
|
|
||||||
self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
|
||||||
self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
|
||||||
|
|
||||||
self._loadFeatureInterfaces()
|
|
||||||
self._loadFeatureServices()
|
|
||||||
|
|
||||||
def __getattr__(self, name: str):
|
|
||||||
"""Lazy-resolve services via serviceCenter on first access."""
|
|
||||||
if name.startswith('_'):
|
|
||||||
raise AttributeError(name)
|
|
||||||
try:
|
|
||||||
from modules.serviceCenter import getService
|
|
||||||
service = getService(name, self._serviceCenterContext)
|
|
||||||
wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {})
|
|
||||||
functionsOnly = wrapping.get("functionsOnly", True)
|
|
||||||
wrapped = PublicService(service, functionsOnly=functionsOnly)
|
|
||||||
setattr(self, name, wrapped)
|
|
||||||
return wrapped
|
|
||||||
except KeyError:
|
|
||||||
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
||||||
|
|
||||||
def _loadFeatureInterfaces(self):
|
|
||||||
"""Dynamically load interfaces from feature containers by filename pattern."""
|
|
||||||
pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py")
|
|
||||||
for filepath in glob.glob(pattern):
|
|
||||||
try:
|
|
||||||
featureDir = os.path.basename(os.path.dirname(filepath))
|
|
||||||
filename = os.path.basename(filepath)[:-3]
|
|
||||||
|
|
||||||
modulePath = f"modules.features.{featureDir}.{filename}"
|
|
||||||
module = importlib.import_module(modulePath)
|
|
||||||
|
|
||||||
if hasattr(module, "getInterface"):
|
|
||||||
interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
|
|
||||||
attrName = filename.replace("interfaceFeature", "interfaceDb")
|
|
||||||
setattr(self, attrName, interface)
|
|
||||||
logger.debug(f"Loaded interface: {attrName} from {modulePath}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not load interface from {filepath}: {e}")
|
|
||||||
|
|
||||||
def _loadFeatureServices(self):
|
|
||||||
"""Dynamically load services from feature containers by filename pattern."""
|
|
||||||
pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py")
|
|
||||||
for filepath in glob.glob(pattern):
|
|
||||||
try:
|
|
||||||
serviceDir = os.path.basename(os.path.dirname(filepath))
|
|
||||||
featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath)))
|
|
||||||
filename = os.path.basename(filepath)[:-3]
|
|
||||||
|
|
||||||
modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}"
|
|
||||||
module = importlib.import_module(modulePath)
|
|
||||||
|
|
||||||
serviceClass = None
|
|
||||||
for attrName in dir(module):
|
|
||||||
if attrName.endswith("Service") and not attrName.startswith("_"):
|
|
||||||
cls = getattr(module, attrName)
|
|
||||||
if isinstance(cls, type):
|
|
||||||
serviceClass = cls
|
|
||||||
break
|
|
||||||
|
|
||||||
if serviceClass:
|
|
||||||
attrName = serviceDir.replace("service", "").lower()
|
|
||||||
if not attrName:
|
|
||||||
attrName = serviceDir.lower()
|
|
||||||
|
|
||||||
functionsOnly = attrName != "ai"
|
|
||||||
|
|
||||||
def _makeServiceResolver(hub):
|
|
||||||
def _resolver(depKey: str):
|
|
||||||
return getattr(hub, depKey)
|
|
||||||
return _resolver
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
sig = inspect.signature(serviceClass.__init__)
|
|
||||||
paramCount = len([p for p in sig.parameters if p != 'self'])
|
|
||||||
if paramCount >= 2:
|
|
||||||
serviceInstance = serviceClass(self, _makeServiceResolver(self))
|
|
||||||
else:
|
|
||||||
serviceInstance = serviceClass(self)
|
|
||||||
setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
|
|
||||||
logger.debug(f"Loaded service: {attrName} from {modulePath}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not load service from {filepath}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# Backward-compatible alias
|
|
||||||
Services = ServiceHub
|
|
||||||
|
|
||||||
|
|
||||||
def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub:
|
|
||||||
"""Get ServiceHub instance for the given user, mandate, and feature instance context."""
|
|
||||||
return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
|
||||||
|
|
|
||||||
64
modules/shared/documentUtils.py
Normal file
64
modules/shared/documentUtils.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Document utility functions (Layer L0 - shared).
|
||||||
|
Pure text-processing helpers with zero internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def parseInlineRuns(text: str) -> list:
|
||||||
|
"""
|
||||||
|
Parse inline markdown formatting into a list of InlineRun dicts.
|
||||||
|
Handles: images, links, bold, italic, inline code, plain text.
|
||||||
|
Uses a regex-based tokenizer that processes tokens left-to-right.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return [{"type": "text", "value": ""}]
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(
|
||||||
|
r'!\[(?P<imgAlt>[^\]]*)\]\((?P<imgSrc>[^)"]+)(?:\s+"(?P<imgWidth>\d+)pt")?\)'
|
||||||
|
r'|\[(?P<linkText>[^\]]+)\]\((?P<linkHref>[^)]+)\)'
|
||||||
|
r'|`(?P<code>[^`]+)`'
|
||||||
|
r'|\*\*(?P<bold>.+?)\*\*'
|
||||||
|
r'|(?<!\w)\*(?P<italic1>.+?)\*(?!\w)'
|
||||||
|
r'|(?<!\w)_(?P<italic2>.+?)_(?!\w)'
|
||||||
|
)
|
||||||
|
|
||||||
|
runs = []
|
||||||
|
lastEnd = 0
|
||||||
|
|
||||||
|
for m in _TOKEN_RE.finditer(text):
|
||||||
|
if m.start() > lastEnd:
|
||||||
|
runs.append({"type": "text", "value": text[lastEnd:m.start()]})
|
||||||
|
|
||||||
|
if m.group("imgAlt") is not None or m.group("imgSrc") is not None:
|
||||||
|
alt = (m.group("imgAlt") or "").strip() or "Image"
|
||||||
|
src = (m.group("imgSrc") or "").strip()
|
||||||
|
widthStr = m.group("imgWidth")
|
||||||
|
run = {"type": "image", "value": alt}
|
||||||
|
if src.startswith("file:"):
|
||||||
|
run["fileId"] = src[5:]
|
||||||
|
else:
|
||||||
|
run["href"] = src
|
||||||
|
if widthStr:
|
||||||
|
run["widthPt"] = int(widthStr)
|
||||||
|
runs.append(run)
|
||||||
|
elif m.group("linkText") is not None:
|
||||||
|
runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")})
|
||||||
|
elif m.group("code") is not None:
|
||||||
|
runs.append({"type": "code", "value": m.group("code")})
|
||||||
|
elif m.group("bold") is not None:
|
||||||
|
runs.append({"type": "bold", "value": m.group("bold")})
|
||||||
|
elif m.group("italic1") is not None:
|
||||||
|
runs.append({"type": "italic", "value": m.group("italic1")})
|
||||||
|
elif m.group("italic2") is not None:
|
||||||
|
runs.append({"type": "italic", "value": m.group("italic2")})
|
||||||
|
|
||||||
|
lastEnd = m.end()
|
||||||
|
|
||||||
|
if lastEnd < len(text):
|
||||||
|
runs.append({"type": "text", "value": text[lastEnd:]})
|
||||||
|
|
||||||
|
return runs if runs else [{"type": "text", "value": text}]
|
||||||
167
modules/shared/eventManager.py
Normal file
167
modules/shared/eventManager.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Event manager for SSE streaming (Layer L0 - shared).
|
||||||
|
Manages event queues for Server-Sent Events (SSE) streaming across features.
|
||||||
|
Generic pub/sub infrastructure with zero internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventManager:
|
||||||
|
"""
|
||||||
|
Manages event queues for SSE streaming.
|
||||||
|
Each workflow has its own async queue for events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the event manager."""
|
||||||
|
self._queues: Dict[str, asyncio.Queue] = {}
|
||||||
|
self._cleanup_tasks: Dict[str, asyncio.Task] = {}
|
||||||
|
self._agent_tasks: Dict[str, asyncio.Task] = {}
|
||||||
|
self._cancelled: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
def create_queue(self, workflow_id: str) -> asyncio.Queue:
|
||||||
|
"""Create an event queue for a workflow."""
|
||||||
|
if workflow_id in self._cleanup_tasks:
|
||||||
|
self._cleanup_tasks[workflow_id].cancel()
|
||||||
|
del self._cleanup_tasks[workflow_id]
|
||||||
|
logger.debug(f"Cancelled pending cleanup for workflow {workflow_id}")
|
||||||
|
|
||||||
|
if workflow_id not in self._queues:
|
||||||
|
self._queues[workflow_id] = asyncio.Queue()
|
||||||
|
logger.debug(f"Created event queue for workflow {workflow_id}")
|
||||||
|
else:
|
||||||
|
old = self._queues[workflow_id]
|
||||||
|
while not old.empty():
|
||||||
|
try:
|
||||||
|
old.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
logger.debug(f"Reusing event queue for workflow {workflow_id} (drained stale events)")
|
||||||
|
return self._queues[workflow_id]
|
||||||
|
|
||||||
|
def get_queue(self, workflow_id: str) -> Optional[asyncio.Queue]:
|
||||||
|
"""Get the event queue for a workflow."""
|
||||||
|
return self._queues.get(workflow_id)
|
||||||
|
|
||||||
|
def has_queue(self, workflow_id: str) -> bool:
|
||||||
|
"""Check if a queue exists for a workflow."""
|
||||||
|
return workflow_id in self._queues
|
||||||
|
|
||||||
|
def register_agent_task(self, workflow_id: str, task: asyncio.Task) -> None:
|
||||||
|
"""Register the asyncio Task running the agent for a workflow."""
|
||||||
|
self._agent_tasks[workflow_id] = task
|
||||||
|
self._cancelled.pop(workflow_id, None)
|
||||||
|
|
||||||
|
def is_cancelled(self, workflow_id: str) -> bool:
|
||||||
|
"""Check if a workflow has been cancelled."""
|
||||||
|
return self._cancelled.get(workflow_id, False)
|
||||||
|
|
||||||
|
async def cancel_agent(self, workflow_id: str) -> bool:
|
||||||
|
"""Cancel the running agent task for a workflow. Returns True if cancelled."""
|
||||||
|
self._cancelled[workflow_id] = True
|
||||||
|
task = self._agent_tasks.pop(workflow_id, None)
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
logger.info(f"Cancelled agent task for workflow {workflow_id}")
|
||||||
|
return True
|
||||||
|
logger.debug(f"No running agent task found for workflow {workflow_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unregister_agent_task(self, workflow_id: str) -> None:
|
||||||
|
"""Remove the agent task reference after completion."""
|
||||||
|
self._agent_tasks.pop(workflow_id, None)
|
||||||
|
self._cancelled.pop(workflow_id, None)
|
||||||
|
|
||||||
|
async def emit_event(
|
||||||
|
self,
|
||||||
|
context_id: str,
|
||||||
|
event_type: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
event_category: str = "chat",
|
||||||
|
message: Optional[str] = None,
|
||||||
|
step: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Emit an event to the queue for a workflow."""
|
||||||
|
queue = self._queues.get(context_id)
|
||||||
|
if not queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": event_type,
|
||||||
|
"data": data,
|
||||||
|
"category": event_category,
|
||||||
|
"message": message,
|
||||||
|
"step": step
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await queue.put(event)
|
||||||
|
if event_type not in ("chunk",):
|
||||||
|
logger.debug(f"Emitted {event_type} event for workflow {context_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error emitting event for workflow {context_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def cleanup(self, workflow_id: str, delay: float = 60.0) -> None:
|
||||||
|
"""Schedule cleanup of a queue after a delay."""
|
||||||
|
if workflow_id in self._cleanup_tasks:
|
||||||
|
self._cleanup_tasks[workflow_id].cancel()
|
||||||
|
|
||||||
|
async def _cleanup():
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if workflow_id in self._queues:
|
||||||
|
queue = self._queues[workflow_id]
|
||||||
|
while not queue.empty():
|
||||||
|
try:
|
||||||
|
queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
del self._queues[workflow_id]
|
||||||
|
logger.info(f"Cleaned up event queue for workflow {workflow_id}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(f"Cleanup cancelled for workflow {workflow_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during cleanup for workflow {workflow_id}: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if workflow_id in self._cleanup_tasks:
|
||||||
|
del self._cleanup_tasks[workflow_id]
|
||||||
|
|
||||||
|
task = asyncio.create_task(_cleanup())
|
||||||
|
self._cleanup_tasks[workflow_id] = task
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Cancel all pending cleanup and agent tasks for fast process exit."""
|
||||||
|
for _wfId, q in list(self._queues.items()):
|
||||||
|
try:
|
||||||
|
q.put_nowait(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for wfId, task in list(self._cleanup_tasks.items()):
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
self._cleanup_tasks.clear()
|
||||||
|
for wfId, task in list(self._agent_tasks.items()):
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
self._agent_tasks.clear()
|
||||||
|
self._queues.clear()
|
||||||
|
logger.info("EventManager shutdown: all tasks cancelled, queues drained")
|
||||||
|
|
||||||
|
|
||||||
|
# Global event manager instance
|
||||||
|
_event_manager: Optional[EventManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_manager() -> EventManager:
|
||||||
|
"""Get the global event manager instance."""
|
||||||
|
global _event_manager
|
||||||
|
if _event_manager is None:
|
||||||
|
_event_manager = EventManager()
|
||||||
|
return _event_manager
|
||||||
59
modules/shared/featureDiscovery.py
Normal file
59
modules/shared/featureDiscovery.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Feature discovery utility (Layer L0 - shared).
|
||||||
|
Dynamically discovers and loads feature main modules from the features directory.
|
||||||
|
Zero internal dependencies — only os, glob, importlib, logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FEATURES_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "features"
|
||||||
|
)
|
||||||
|
|
||||||
|
_cachedMainModules = None
|
||||||
|
|
||||||
|
|
||||||
|
def loadFeatureMainModules() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Dynamically load main modules from all discovered feature containers.
|
||||||
|
Results are cached after the first call.
|
||||||
|
"""
|
||||||
|
global _cachedMainModules
|
||||||
|
if _cachedMainModules is not None:
|
||||||
|
return _cachedMainModules
|
||||||
|
|
||||||
|
mainModules = {}
|
||||||
|
pattern = os.path.join(FEATURES_DIR, "*", "main*.py")
|
||||||
|
|
||||||
|
for filepath in glob.glob(pattern):
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
if filename == "__init__.py":
|
||||||
|
continue
|
||||||
|
|
||||||
|
featureDir = os.path.basename(os.path.dirname(filepath))
|
||||||
|
if featureDir.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if featureDir in mainModules:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mainFile = filename[:-3]
|
||||||
|
|
||||||
|
try:
|
||||||
|
modulePath = f"modules.features.{featureDir}.{mainFile}"
|
||||||
|
module = importlib.import_module(modulePath)
|
||||||
|
mainModules[featureDir] = module
|
||||||
|
logger.debug(f"Loaded main module: {featureDir}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load main module from {featureDir}: {e}")
|
||||||
|
|
||||||
|
_cachedMainModules = mainModules
|
||||||
|
return mainModules
|
||||||
|
|
@ -561,39 +561,27 @@ def _deleteUserDataFromFeatureDatabases(userId: str, currentUser) -> Dict[str, A
|
||||||
|
|
||||||
logger.info(f"Found {len(featureCodes)} feature types to process: {featureCodes}")
|
logger.info(f"Found {len(featureCodes)} feature types to process: {featureCodes}")
|
||||||
|
|
||||||
# Process each feature type
|
# Process each feature type via lifecycle hooks
|
||||||
|
from modules.shared.featureDiscovery import loadFeatureMainModules
|
||||||
|
featureModules = loadFeatureMainModules()
|
||||||
|
|
||||||
for featureCode in featureCodes:
|
for featureCode in featureCodes:
|
||||||
try:
|
try:
|
||||||
dbName = f"poweron_{featureCode}"
|
featureModule = featureModules.get(featureCode)
|
||||||
|
hook = getattr(featureModule, "onUserDelete", None) if featureModule else None
|
||||||
# Try to get feature interface
|
|
||||||
featureInterface = None
|
if hook is None:
|
||||||
|
logger.warning(f"No onUserDelete hook for feature: {featureCode}")
|
||||||
if featureCode == "trustee":
|
|
||||||
from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface
|
|
||||||
featureInterface = getTrusteeInterface(currentUser)
|
|
||||||
elif featureCode == "realestate":
|
|
||||||
from modules.features.realestate.interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
|
||||||
featureInterface = getRealEstateInterface(currentUser)
|
|
||||||
elif featureCode == "neutralization":
|
|
||||||
from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutralizerInterface
|
|
||||||
featureInterface = getNeutralizerInterface(currentUser)
|
|
||||||
else:
|
|
||||||
logger.warning(f"No interface found for feature code: {featureCode}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if featureInterface and hasattr(featureInterface, 'db'):
|
featureStats = hook(userId, currentUser)
|
||||||
featureStats = deleteUserDataFromDatabase(
|
if featureStats:
|
||||||
featureInterface.db,
|
|
||||||
userId,
|
|
||||||
dbName
|
|
||||||
)
|
|
||||||
stats["databases"].append(featureStats)
|
stats["databases"].append(featureStats)
|
||||||
stats["totalTablesProcessed"] += featureStats["tablesProcessed"]
|
stats["totalTablesProcessed"] += featureStats.get("tablesProcessed", 0)
|
||||||
stats["totalRecordsDeleted"] += featureStats["recordsDeleted"]
|
stats["totalRecordsDeleted"] += featureStats.get("recordsDeleted", 0)
|
||||||
stats["totalRecordsAnonymized"] += featureStats["recordsAnonymized"]
|
stats["totalRecordsAnonymized"] += featureStats.get("recordsAnonymized", 0)
|
||||||
stats["errors"].extend(featureStats["errors"])
|
stats["errors"].extend(featureStats.get("errors", []))
|
||||||
|
|
||||||
except Exception as featureErr:
|
except Exception as featureErr:
|
||||||
errorMsg = f"Error processing feature {featureCode}: {featureErr}"
|
errorMsg = f"Error processing feature {featureCode}: {featureErr}"
|
||||||
logger.warning(errorMsg)
|
logger.warning(errorMsg)
|
||||||
|
|
|
||||||
|
|
@ -202,19 +202,19 @@ def _registerRbacLabels():
|
||||||
logger.info("i18n rbac labels: %d new keys (rbac.* context)", added)
|
logger.info("i18n rbac labels: %d new keys (rbac.* context)", added)
|
||||||
|
|
||||||
|
|
||||||
def _registerServiceCenterLabels():
|
def _registerServiceCenterLabels(serviceLabels: list = None):
|
||||||
"""Register service-center category labels and bootstrap role descriptions."""
|
"""Register service-center category labels and bootstrap role descriptions.
|
||||||
|
|
||||||
|
serviceLabels is injected by app.py (Composition Root) to avoid
|
||||||
|
system(L4) → serviceCenter(L5) upward import.
|
||||||
|
"""
|
||||||
added = 0
|
added = 0
|
||||||
|
|
||||||
try:
|
for label in (serviceLabels or []):
|
||||||
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
|
key = _extractRegistrySourceText(label)
|
||||||
for svc in IMPORTABLE_SERVICES.values():
|
if key and key not in _REGISTRY:
|
||||||
key = _extractRegistrySourceText(svc.get("label"))
|
_REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
|
||||||
if key and key not in _REGISTRY:
|
added += 1
|
||||||
_REGISTRY[key] = _I18nRegistryEntry(context="service", value="")
|
|
||||||
added += 1
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
_bootstrapRoleDescriptions = [
|
_bootstrapRoleDescriptions = [
|
||||||
"Administrator - Benutzer und Ressourcen im Mandanten verwalten",
|
"Administrator - Benutzer und Ressourcen im Mandanten verwalten",
|
||||||
|
|
@ -288,38 +288,28 @@ def _registerNodeLabels():
|
||||||
logger.info("i18n node labels: %d new keys (node.*/port.* context)", added)
|
logger.info("i18n node labels: %d new keys (node.*/port.* context)", added)
|
||||||
|
|
||||||
|
|
||||||
def _registerAccountingConnectorLabels():
|
def _registerAccountingConnectorLabels(accountingLabels: list = None):
|
||||||
"""Register all accounting connector configField labels at boot time."""
|
"""Register accounting connector configField labels at boot time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accountingLabels: List of dicts with keys 'label' and 'connectorType',
|
||||||
|
injected from app.py to avoid features-import.
|
||||||
|
"""
|
||||||
|
if not accountingLabels:
|
||||||
|
return
|
||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
try:
|
for entry in accountingLabels:
|
||||||
from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
|
key = entry.get("label", "")
|
||||||
except ImportError:
|
connectorType = entry.get("connectorType", "unknown")
|
||||||
logger.debug("i18n accounting connectors: registry not importable")
|
if not isinstance(key, str) or not key:
|
||||||
return
|
continue
|
||||||
|
if key not in _REGISTRY:
|
||||||
try:
|
_REGISTRY[key] = _I18nRegistryEntry(
|
||||||
registry = getAccountingRegistry()
|
context=f"connector.accounting.{connectorType}",
|
||||||
except Exception as e:
|
value="",
|
||||||
logger.warning("i18n accounting connectors: registry init failed: %s", e)
|
|
||||||
return
|
|
||||||
|
|
||||||
for connectorType, connector in (registry._connectors or {}).items():
|
|
||||||
try:
|
|
||||||
for field in connector.getRequiredConfigFields():
|
|
||||||
key = getattr(field, "label", "") or ""
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
continue
|
|
||||||
if key not in _REGISTRY:
|
|
||||||
_REGISTRY[key] = _I18nRegistryEntry(
|
|
||||||
context=f"connector.accounting.{connectorType}",
|
|
||||||
value="",
|
|
||||||
)
|
|
||||||
added += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"i18n accounting connector %s: failed to read fields: %s",
|
|
||||||
connectorType, e,
|
|
||||||
)
|
)
|
||||||
|
added += 1
|
||||||
|
|
||||||
logger.info("i18n accounting connector labels: %d new keys", added)
|
logger.info("i18n accounting connector labels: %d new keys", added)
|
||||||
|
|
||||||
|
|
@ -385,16 +375,21 @@ def _registerDatamodelOptionLabels():
|
||||||
# Public boot API (called by app.py)
|
# Public boot API (called by app.py)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def syncRegistryToDb():
|
async def syncRegistryToDb(serviceLabels: list = None, accountingLabels: list = None):
|
||||||
"""Boot hook: discover all i18n keys and write them into UiLanguageSet(xx)."""
|
"""Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serviceLabels: Service label strings injected from app.py (avoids upward import).
|
||||||
|
accountingLabels: Accounting connector field labels injected from app.py.
|
||||||
|
"""
|
||||||
_scanRouteApiMsgKeys()
|
_scanRouteApiMsgKeys()
|
||||||
_registerNavLabels()
|
_registerNavLabels()
|
||||||
_registerFeatureUiLabels()
|
_registerFeatureUiLabels()
|
||||||
_registerRbacLabels()
|
_registerRbacLabels()
|
||||||
_registerServiceCenterLabels()
|
_registerServiceCenterLabels(serviceLabels)
|
||||||
_registerNodeLabels()
|
_registerNodeLabels()
|
||||||
_registerDatamodelOptionLabels()
|
_registerDatamodelOptionLabels()
|
||||||
_registerAccountingConnectorLabels()
|
_registerAccountingConnectorLabels(accountingLabels)
|
||||||
|
|
||||||
if not _REGISTRY:
|
if not _REGISTRY:
|
||||||
logger.info("i18n registry: no keys to sync (empty registry)")
|
logger.info("i18n registry: no keys to sync (empty registry)")
|
||||||
|
|
|
||||||
|
|
@ -20,351 +20,7 @@ FEATURE_CODE = "system"
|
||||||
FEATURE_LABEL = "System"
|
FEATURE_LABEL = "System"
|
||||||
FEATURE_ICON = "mdi-cog"
|
FEATURE_ICON = "mdi-cog"
|
||||||
|
|
||||||
# =============================================================================
|
from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS # noqa: F401 — canonical source
|
||||||
# Navigation Structure (Single Source of Truth)
|
|
||||||
# =============================================================================
|
|
||||||
#
|
|
||||||
# Block Order (gemäss Navigation-API-Konzept):
|
|
||||||
# - System: 10
|
|
||||||
# - <dynamic/features>: 15 (wird in routeSystem.py eingefügt)
|
|
||||||
# - Basisdaten: 30
|
|
||||||
# - Administration: 200
|
|
||||||
#
|
|
||||||
# NOTE: Workflows and Migrate sections removed - now handled as features
|
|
||||||
#
|
|
||||||
# Item Order: Default-Abstand 10 pro Item
|
|
||||||
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
|
|
||||||
# icon: Wird intern gehalten aber NICHT in der API Response zurückgegeben
|
|
||||||
|
|
||||||
NAVIGATION_SECTIONS = [
|
|
||||||
# ─── Meine Sicht (with top-level item + subgroups) ───
|
|
||||||
{
|
|
||||||
"id": "system",
|
|
||||||
"title": t("Meine Sicht"),
|
|
||||||
"order": 10,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "home",
|
|
||||||
"objectKey": "ui.system.home",
|
|
||||||
"label": t("Start"),
|
|
||||||
"icon": "FaHome",
|
|
||||||
"path": "/",
|
|
||||||
"order": 10,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"subgroups": [
|
|
||||||
# ── Übersichten ──
|
|
||||||
{
|
|
||||||
"id": "system-overviews",
|
|
||||||
"title": t("Übersichten"),
|
|
||||||
"order": 15,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "integrations",
|
|
||||||
"objectKey": "ui.system.integrations",
|
|
||||||
"label": t("Integrationen"),
|
|
||||||
"icon": "FaProjectDiagram",
|
|
||||||
"path": "/integrations",
|
|
||||||
"order": 10,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "compliance-audit",
|
|
||||||
"objectKey": "ui.system.complianceAudit",
|
|
||||||
"label": t("Compliance & Audit"),
|
|
||||||
"icon": "FaShieldAlt",
|
|
||||||
"path": "/compliance-audit",
|
|
||||||
"order": 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# ── Basisdaten ──
|
|
||||||
{
|
|
||||||
"id": "system-basedata",
|
|
||||||
"title": t("Basisdaten"),
|
|
||||||
"order": 20,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "connections",
|
|
||||||
"objectKey": "ui.system.connections",
|
|
||||||
"label": t("Verbindungen"),
|
|
||||||
"icon": "FaLink",
|
|
||||||
"path": "/basedata/connections",
|
|
||||||
"order": 10,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "files",
|
|
||||||
"objectKey": "ui.system.files",
|
|
||||||
"label": t("Dateien"),
|
|
||||||
"icon": "FaRegFileAlt",
|
|
||||||
"path": "/basedata/files",
|
|
||||||
"order": 20,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "prompts",
|
|
||||||
"objectKey": "ui.system.prompts",
|
|
||||||
"label": t("Prompts"),
|
|
||||||
"icon": "FaLightbulb",
|
|
||||||
"path": "/basedata/prompts",
|
|
||||||
"order": 30,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# ── Nutzung ──
|
|
||||||
{
|
|
||||||
"id": "system-usage",
|
|
||||||
"title": t("Nutzung"),
|
|
||||||
"order": 30,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "billing-admin",
|
|
||||||
"objectKey": "ui.system.billingAdmin",
|
|
||||||
"label": t("Abrechnung"),
|
|
||||||
"icon": "FaMoneyBillAlt",
|
|
||||||
"path": "/billing/admin",
|
|
||||||
"order": 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "statistics",
|
|
||||||
"objectKey": "ui.system.statistics",
|
|
||||||
"label": t("Statistiken"),
|
|
||||||
"icon": "FaChartBar",
|
|
||||||
"path": "/billing/transactions",
|
|
||||||
"order": 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "automations",
|
|
||||||
"objectKey": "ui.system.automations",
|
|
||||||
"label": t("Automations"),
|
|
||||||
"icon": "FaRobot",
|
|
||||||
"path": "/automations",
|
|
||||||
"order": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "rag-inventory",
|
|
||||||
"objectKey": "ui.system.ragInventory",
|
|
||||||
"label": t("RAG-Inventar"),
|
|
||||||
"icon": "FaDatabase",
|
|
||||||
"path": "/rag-inventory",
|
|
||||||
"order": 35,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "store",
|
|
||||||
"objectKey": "ui.system.store",
|
|
||||||
"label": t("Store"),
|
|
||||||
"icon": "FaStore",
|
|
||||||
"path": "/store",
|
|
||||||
"order": 40,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "settings",
|
|
||||||
"objectKey": "ui.system.settings",
|
|
||||||
"label": t("Einstellungen"),
|
|
||||||
"icon": "FaCog",
|
|
||||||
"path": "/settings",
|
|
||||||
"order": 50,
|
|
||||||
"public": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# ─── Administration (with subgroups) ───
|
|
||||||
{
|
|
||||||
"id": "admin",
|
|
||||||
"title": t("Administration"),
|
|
||||||
"order": 200,
|
|
||||||
"subgroups": [
|
|
||||||
# ── Wizards ──
|
|
||||||
{
|
|
||||||
"id": "admin-wizards",
|
|
||||||
"title": t("Wizards"),
|
|
||||||
"order": 10,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "admin-mandate-wizard",
|
|
||||||
"objectKey": "ui.admin.mandateWizard",
|
|
||||||
"label": t("Mandanten-Wizard"),
|
|
||||||
"icon": "FaMagic",
|
|
||||||
"path": "/admin/mandate-wizard",
|
|
||||||
"order": 10,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-invitation-wizard",
|
|
||||||
"objectKey": "ui.admin.invitationWizard",
|
|
||||||
"label": t("Einladungs-Wizard"),
|
|
||||||
"icon": "FaEnvelopeOpenText",
|
|
||||||
"path": "/admin/invitation-wizard",
|
|
||||||
"order": 20,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# ── Users ──
|
|
||||||
{
|
|
||||||
"id": "admin-users-group",
|
|
||||||
"title": t("Benutzer"),
|
|
||||||
"order": 20,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "admin-users",
|
|
||||||
"objectKey": "ui.admin.users",
|
|
||||||
"label": t("Benutzer"),
|
|
||||||
"icon": "FaUsers",
|
|
||||||
"path": "/admin/users",
|
|
||||||
"order": 10,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-invitations",
|
|
||||||
"objectKey": "ui.admin.invitations",
|
|
||||||
"label": t("Benutzer-Einladungen"),
|
|
||||||
"icon": "FaEnvelopeOpenText",
|
|
||||||
"path": "/admin/invitations",
|
|
||||||
"order": 20,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-user-access-overview",
|
|
||||||
"objectKey": "ui.admin.userAccessOverview",
|
|
||||||
"label": t("Benutzer-Zugriffsübersicht"),
|
|
||||||
"icon": "FaClipboardList",
|
|
||||||
"path": "/admin/user-access-overview",
|
|
||||||
"order": 30,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-subscriptions",
|
|
||||||
"objectKey": "ui.admin.subscriptions",
|
|
||||||
"label": t("Abonnements"),
|
|
||||||
"icon": "FaFileContract",
|
|
||||||
"path": "/admin/subscriptions",
|
|
||||||
"order": 40,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
# ── System ──
|
|
||||||
{
|
|
||||||
"id": "admin-system-group",
|
|
||||||
"title": t("System"),
|
|
||||||
"order": 30,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "admin-roles",
|
|
||||||
"objectKey": "ui.admin.roles",
|
|
||||||
"label": t("Rollen"),
|
|
||||||
"icon": "FaUserTag",
|
|
||||||
"path": "/admin/mandate-roles",
|
|
||||||
"order": 10,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-mandate-role-permissions",
|
|
||||||
"objectKey": "ui.admin.mandateRolePermissions",
|
|
||||||
"label": t("Rollen-Berechtigungen"),
|
|
||||||
"icon": "FaKey",
|
|
||||||
"path": "/admin/mandate-role-permissions",
|
|
||||||
"order": 20,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-mandates",
|
|
||||||
"objectKey": "ui.admin.mandates",
|
|
||||||
"label": t("Mandanten"),
|
|
||||||
"icon": "FaBuilding",
|
|
||||||
"path": "/admin/mandates",
|
|
||||||
"order": 30,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-user-mandates",
|
|
||||||
"objectKey": "ui.admin.userMandates",
|
|
||||||
"label": t("Mandanten-Mitglieder"),
|
|
||||||
"icon": "FaUserFriends",
|
|
||||||
"path": "/admin/user-mandates",
|
|
||||||
"order": 40,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-access",
|
|
||||||
"objectKey": "ui.admin.access",
|
|
||||||
"label": t("Zugriffsverwaltung"),
|
|
||||||
"icon": "FaBuilding",
|
|
||||||
"path": "/admin/access",
|
|
||||||
"order": 50,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-feature-instances",
|
|
||||||
"objectKey": "ui.admin.featureInstances",
|
|
||||||
"label": t("Feature-Instanzen"),
|
|
||||||
"icon": "FaCubes",
|
|
||||||
"path": "/admin/feature-instances",
|
|
||||||
"order": 60,
|
|
||||||
"adminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-feature-roles",
|
|
||||||
"objectKey": "ui.admin.featureRoles",
|
|
||||||
"label": t("Features Rollen-Vorlagen"),
|
|
||||||
"icon": "FaShieldAlt",
|
|
||||||
"path": "/admin/feature-roles",
|
|
||||||
"order": 70,
|
|
||||||
"adminOnly": True,
|
|
||||||
"sysAdminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-logs",
|
|
||||||
"objectKey": "ui.admin.logs",
|
|
||||||
"label": t("Logs"),
|
|
||||||
"icon": "FaFileAlt",
|
|
||||||
"path": "/admin/logs",
|
|
||||||
"order": 90,
|
|
||||||
"adminOnly": True,
|
|
||||||
"sysAdminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-languages",
|
|
||||||
"objectKey": "ui.admin.languages",
|
|
||||||
"label": t("UI-Sprachen"),
|
|
||||||
"icon": "FaGlobe",
|
|
||||||
"path": "/admin/languages",
|
|
||||||
"order": 95,
|
|
||||||
"adminOnly": True,
|
|
||||||
"sysAdminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-database-health",
|
|
||||||
"objectKey": "ui.admin.databaseHealth",
|
|
||||||
"label": t("Datenbank-Gesundheit"),
|
|
||||||
"icon": "FaDatabase",
|
|
||||||
"path": "/admin/database-health",
|
|
||||||
"order": 98,
|
|
||||||
"adminOnly": True,
|
|
||||||
"sysAdminOnly": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "admin-demo-config",
|
|
||||||
"objectKey": "ui.admin.demoConfig",
|
|
||||||
"label": t("Demo Config"),
|
|
||||||
"icon": "FaCubes",
|
|
||||||
"path": "/admin/demo-config",
|
|
||||||
"order": 100,
|
|
||||||
"adminOnly": True,
|
|
||||||
"sysAdminOnly": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def objectKeyToUiComponent(objectKey: str) -> str:
|
def objectKeyToUiComponent(objectKey: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -89,45 +89,7 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
_cachedMainModules = None
|
from modules.shared.featureDiscovery import loadFeatureMainModules # noqa: F401 — re-export
|
||||||
|
|
||||||
def loadFeatureMainModules() -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Dynamically load main modules from all discovered feature containers.
|
|
||||||
Results are cached after the first call.
|
|
||||||
"""
|
|
||||||
global _cachedMainModules
|
|
||||||
if _cachedMainModules is not None:
|
|
||||||
return _cachedMainModules
|
|
||||||
|
|
||||||
mainModules = {}
|
|
||||||
pattern = os.path.join(FEATURES_DIR, "*", "main*.py")
|
|
||||||
|
|
||||||
for filepath in glob.glob(pattern):
|
|
||||||
filename = os.path.basename(filepath)
|
|
||||||
if filename == "__init__.py":
|
|
||||||
continue
|
|
||||||
|
|
||||||
featureDir = os.path.basename(os.path.dirname(filepath))
|
|
||||||
if featureDir.startswith("_"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if this feature already has a main module loaded (avoid duplicates)
|
|
||||||
if featureDir in mainModules:
|
|
||||||
continue
|
|
||||||
|
|
||||||
mainFile = filename[:-3] # Remove .py
|
|
||||||
|
|
||||||
try:
|
|
||||||
modulePath = f"modules.features.{featureDir}.{mainFile}"
|
|
||||||
module = importlib.import_module(modulePath)
|
|
||||||
mainModules[featureDir] = module
|
|
||||||
logger.debug(f"Loaded main module: {featureDir}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load main module from {featureDir}: {e}")
|
|
||||||
|
|
||||||
_cachedMainModules = mainModules
|
|
||||||
return mainModules
|
|
||||||
|
|
||||||
|
|
||||||
def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
||||||
|
|
@ -150,16 +112,8 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
||||||
logger.error(f"Error registering system RBAC objects: {e}")
|
logger.error(f"Error registering system RBAC objects: {e}")
|
||||||
results["system"] = False
|
results["system"] = False
|
||||||
|
|
||||||
# Register service center RBAC objects (service.web, service.extraction, etc.)
|
# Service center RBAC objects are registered by app.py (Composition Root)
|
||||||
try:
|
# to avoid system(L4) → serviceCenter(L5) upward import.
|
||||||
from modules.serviceCenter import registerServiceObjects
|
|
||||||
success = registerServiceObjects(catalogService)
|
|
||||||
results["servicecenter"] = success
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning(f"Service center not found, skipping service RBAC registration: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error registering service RBAC objects: {e}")
|
|
||||||
results["servicecenter"] = False
|
|
||||||
|
|
||||||
# Register feature modules
|
# Register feature modules
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from modules.workflows.automation2.executors import (
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
|
from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
||||||
from modules.workflows.automation2.graphicalEditorRunFileLogger import (
|
from modules.workflows.automation2.graphicalEditorRunFileLogger import (
|
||||||
GraphicalEditorRunFileLogger,
|
GraphicalEditorRunFileLogger,
|
||||||
graphical_editor_run_file_logging_enabled,
|
graphical_editor_run_file_logging_enabled,
|
||||||
|
|
@ -252,7 +252,7 @@ def _merge_node_parameters_into_snap(
|
||||||
def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None:
|
def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None:
|
||||||
"""Emit a step-log SSE event to any listening client for this run."""
|
"""Emit a step-log SSE event to any listening client for this run."""
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager
|
from modules.shared.eventManager import get_event_manager
|
||||||
em = get_event_manager()
|
em = get_event_manager()
|
||||||
queueId = f"run-trace-{runId}"
|
queueId = f"run-trace-{runId}"
|
||||||
if not em.has_queue(queueId):
|
if not em.has_queue(queueId):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from modules.features.graphicalEditor.portTypes import (
|
||||||
_normalizeError,
|
_normalizeError,
|
||||||
normalizeToSchema,
|
normalizeToSchema,
|
||||||
)
|
)
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError
|
||||||
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
|
from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError
|
||||||
from modules.workflows.methods.methodContext.actions.extractContent import (
|
from modules.workflows.methods.methodContext.actions.extractContent import (
|
||||||
PRESENTATION_KIND,
|
PRESENTATION_KIND,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Any, Dict, List
|
||||||
|
|
||||||
from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum
|
from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum
|
||||||
from modules.datamodels.datamodelChat import ActionResult
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.datamodels.datamodelExtraction import ContentPart
|
from modules.datamodels.datamodelExtraction import ContentPart
|
||||||
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
||||||
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
|
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.datamodels.datamodelExtraction import ContentPart
|
from modules.datamodels.datamodelExtraction import ContentPart
|
||||||
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
||||||
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
|
from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Dict, Any, List, Optional
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
|
||||||
from modules.datamodels.datamodelExtraction import ContentPart
|
from modules.datamodels.datamodelExtraction import ContentPart
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import re
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
||||||
from modules.serviceCenter import ServiceCenterContext, getService, can_access_service
|
from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
||||||
from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -45,6 +44,8 @@ def _build_research_prompt(parameters: Dict[str, Any]) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
from modules.serviceCenter import ServiceCenterContext, getService, can_access_service
|
||||||
|
|
||||||
operationId = None
|
operationId = None
|
||||||
try:
|
try:
|
||||||
prompt = _build_research_prompt(parameters)
|
prompt = _build_research_prompt(parameters)
|
||||||
|
|
|
||||||
|
|
@ -1399,7 +1399,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes]
|
||||||
|
|
||||||
def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]:
|
def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]:
|
||||||
"""Map presentation ``lines`` to inline runs, preserving line order with explicit breaks."""
|
"""Map presentation ``lines`` to inline runs, preserving line order with explicit breaks."""
|
||||||
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns
|
from modules.shared.documentUtils import parseInlineRuns
|
||||||
|
|
||||||
runs: List[Dict[str, Any]] = []
|
runs: List[Dict[str, Any]] = []
|
||||||
first = True
|
first = True
|
||||||
|
|
@ -1537,7 +1537,7 @@ def presentation_envelopes_to_document_json(
|
||||||
services: Any = None,
|
services: Any = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections)."""
|
"""Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections)."""
|
||||||
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns
|
from modules.shared.documentUtils import parseInlineRuns
|
||||||
|
|
||||||
envelopes = normalize_presentation_envelopes(raw)
|
envelopes = normalize_presentation_envelopes(raw)
|
||||||
if not envelopes:
|
if not envelopes:
|
||||||
|
|
|
||||||
|
|
@ -34,57 +34,78 @@ def _collectActionsUnfiltered(methodInstance) -> Dict[str, Dict[str, Any]]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _registerMethodClasses(serviceCenter, modulePath: str, uniqueCount: int) -> int:
|
||||||
|
"""Import a method module and register all MethodBase subclasses found in it."""
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(modulePath)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error importing method module {modulePath}: {e}")
|
||||||
|
return uniqueCount
|
||||||
|
|
||||||
|
for itemName, item in inspect.getmembers(module):
|
||||||
|
if (inspect.isclass(item) and
|
||||||
|
issubclass(item, MethodBase) and
|
||||||
|
item != MethodBase):
|
||||||
|
|
||||||
|
if itemName in methods:
|
||||||
|
continue
|
||||||
|
|
||||||
|
shortName = itemName.replace('Method', '').lower()
|
||||||
|
methodInstance = item(serviceCenter)
|
||||||
|
actions = _collectActionsUnfiltered(methodInstance)
|
||||||
|
|
||||||
|
methodInfo = {
|
||||||
|
'instance': methodInstance,
|
||||||
|
'actions': actions,
|
||||||
|
'description': item.__doc__ or f"Method {itemName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
methods[itemName] = methodInfo
|
||||||
|
methods[shortName] = methodInfo
|
||||||
|
uniqueCount += 1
|
||||||
|
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
|
||||||
|
|
||||||
|
return uniqueCount
|
||||||
|
|
||||||
|
|
||||||
def discoverMethods(serviceCenter):
|
def discoverMethods(serviceCenter):
|
||||||
"""Dynamically discover all method classes and their actions in modules methods package.
|
"""Dynamically discover all method classes and their actions.
|
||||||
|
|
||||||
Always creates fresh method instances bound to the given serviceCenter,
|
Scans two locations:
|
||||||
preventing stale or cross-workflow service references.
|
1. modules.workflows.methods (core methods)
|
||||||
|
2. modules.features.*/workflows/ (feature-owned methods)
|
||||||
"""
|
"""
|
||||||
global methods
|
global methods
|
||||||
try:
|
try:
|
||||||
methodsPackage = importlib.import_module('modules.workflows.methods')
|
methodsPackage = importlib.import_module('modules.workflows.methods')
|
||||||
|
|
||||||
# Clear and rebuild to prevent cross-workflow state contamination
|
|
||||||
methods.clear()
|
methods.clear()
|
||||||
uniqueCount = 0
|
uniqueCount = 0
|
||||||
|
|
||||||
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
|
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
|
||||||
if name.startswith('method'):
|
if name.startswith('method'):
|
||||||
try:
|
uniqueCount = _registerMethodClasses(
|
||||||
module = importlib.import_module(f'modules.workflows.methods.{name}')
|
serviceCenter, f'modules.workflows.methods.{name}', uniqueCount
|
||||||
|
)
|
||||||
for itemName, item in inspect.getmembers(module):
|
|
||||||
if (inspect.isclass(item) and
|
# Feature-owned methods (e.g. features/trustee/workflows/methodTrustee)
|
||||||
issubclass(item, MethodBase) and
|
import os
|
||||||
item != MethodBase):
|
import glob as _glob
|
||||||
|
featuresDir = os.path.join(
|
||||||
shortName = itemName.replace('Method', '').lower()
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"features"
|
||||||
# Skip if already processed (via another module path)
|
)
|
||||||
if itemName in methods:
|
for wfInit in _glob.glob(os.path.join(featuresDir, "*", "workflows", "__init__.py")):
|
||||||
continue
|
wfDir = os.path.dirname(wfInit)
|
||||||
|
featureName = os.path.basename(os.path.dirname(wfDir))
|
||||||
methodInstance = item(serviceCenter)
|
for entry in os.listdir(wfDir):
|
||||||
actions = _collectActionsUnfiltered(methodInstance)
|
entryPath = os.path.join(wfDir, entry)
|
||||||
|
if os.path.isdir(entryPath) and entry.startswith("method"):
|
||||||
methodInfo = {
|
modulePath = f"modules.features.{featureName}.workflows.{entry}"
|
||||||
'instance': methodInstance,
|
uniqueCount = _registerMethodClasses(serviceCenter, modulePath, uniqueCount)
|
||||||
'actions': actions,
|
|
||||||
'description': item.__doc__ or f"Method {itemName}"
|
|
||||||
}
|
|
||||||
|
|
||||||
methods[itemName] = methodInfo
|
|
||||||
methods[shortName] = methodInfo
|
|
||||||
uniqueCount += 1
|
|
||||||
|
|
||||||
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error discovering method {name}: {str(e)}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)")
|
logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error discovering methods: {str(e)}")
|
logger.error(f"Error discovering methods: {str(e)}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,48 +396,29 @@ def _createRunFailedNotification(
|
||||||
logger.warning("Failed to create in-app run.failed notification: %s", e)
|
logger.warning("Failed to create in-app run.failed notification: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
_onRunFailedCallback = None
|
||||||
|
|
||||||
|
|
||||||
|
def setOnRunFailedCallback(callback) -> None:
|
||||||
|
"""Set the callback for run failure notifications (injected by app.py)."""
|
||||||
|
global _onRunFailedCallback
|
||||||
|
_onRunFailedCallback = callback
|
||||||
|
|
||||||
|
|
||||||
def _triggerRunFailedSubscription(
|
def _triggerRunFailedSubscription(
|
||||||
workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
|
workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Trigger the messaging subscription for run failures (email notifications)."""
|
"""Trigger the messaging subscription for run failures via injected callback."""
|
||||||
|
if _onRunFailedCallback is None:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter import getService
|
_onRunFailedCallback(
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
workflowId=workflowId,
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
runId=runId,
|
||||||
from modules.datamodels.datamodelMessaging import MessagingEventParameters
|
error=error,
|
||||||
|
mandateId=mandateId,
|
||||||
rootInterface = getRootInterface()
|
workflowLabel=workflowLabel,
|
||||||
if not rootInterface:
|
|
||||||
return
|
|
||||||
eventUser = rootInterface.getUserByUsername("event")
|
|
||||||
if not eventUser:
|
|
||||||
return
|
|
||||||
|
|
||||||
ctx = ServiceCenterContext(
|
|
||||||
user=eventUser,
|
|
||||||
mandate_id=mandateId or "",
|
|
||||||
feature_instance_id="",
|
|
||||||
feature_code="graphicalEditor",
|
|
||||||
)
|
)
|
||||||
messagingService = getService("messaging", ctx)
|
|
||||||
|
|
||||||
subscriptionId = "GraphicalEditorRunFailed"
|
|
||||||
eventParams = MessagingEventParameters(triggerData={
|
|
||||||
"workflowId": workflowId,
|
|
||||||
"workflowLabel": workflowLabel or workflowId,
|
|
||||||
"runId": runId,
|
|
||||||
"error": error,
|
|
||||||
"mandateId": mandateId or "",
|
|
||||||
})
|
|
||||||
result = messagingService.executeSubscription(subscriptionId, eventParams)
|
|
||||||
logger.info(
|
|
||||||
"Triggered run.failed subscription: sent=%d success=%s",
|
|
||||||
result.messagesSent, result.success,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.debug("Subscription function GraphicalEditorRunFailed not found (not yet registered)")
|
|
||||||
except ValueError as e:
|
|
||||||
logger.debug("Subscription GraphicalEditorRunFailed: %s", e)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to trigger run.failed subscription: %s", e)
|
logger.warning("Failed to trigger run.failed subscription: %s", e)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue