diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index c5547c0a..8fcf10f2 100644 --- a/modules/datamodels/datamodelSubscription.py +++ b/modules/datamodels/datamodelSubscription.py @@ -31,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA ALLOWED_TRANSITIONS = { (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE), + (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE), diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py index 4bb30f7f..d56804fd 100644 --- a/modules/features/automation/mainAutomation.py +++ b/modules/features/automation/mainAutomation.py @@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]: "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON, - "autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap + "autoCreateInstance": False, } diff --git a/modules/features/automation2/mainAutomation2.py b/modules/features/automation2/mainAutomation2.py index 08038e68..c0bee3fe 100644 --- a/modules/features/automation2/mainAutomation2.py +++ b/modules/features/automation2/mainAutomation2.py @@ -60,12 +60,25 @@ RESOURCE_OBJECTS = [ ] TEMPLATE_ROLES = [ + { + "roleLabel": "automation2-viewer", + "description": { + "en": "Automation2 Viewer - View workflows (read-only)", + "de": "Automation2 Betrachter - Workflows ansehen (nur lesen)", + "fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.automation2.workflows", "view": True}, + {"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, { "roleLabel": "automation2-user", "description": { "en": "Automation2 User - Use automation2 flow builder", "de": "Automation2 Benutzer - Flow-Builder nutzen", - "fr": "Utilisateur Automation2 - Utiliser le flow builder" + "fr": "Utilisateur Automation2 - Utiliser le flow builder", }, "accessRules": [ {"context": "UI", "item": "ui.feature.automation2.editor", "view": True}, @@ -75,7 +88,7 @@ TEMPLATE_ROLES = [ {"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True}, {"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, - ] + ], }, { "roleLabel": "automation2-admin", @@ -188,7 +201,7 @@ def getFeatureDefinition() -> Dict[str, Any]: "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON, - "autoCreateInstance": True, + "autoCreateInstance": False, } diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index e8abcee8..9d949e13 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -109,12 +109,27 @@ RESOURCE_OBJECTS = [ ] TEMPLATE_ROLES = [ + { + "roleLabel": "commcoach-viewer", + "description": { + "en": "Communication Coach Viewer - View coaching data (read-only)", + "de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)", + "fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, + {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, { "roleLabel": "commcoach-user", "description": { "en": "Communication Coach User - Can manage own coaching contexts and sessions", "de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", - "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions" + "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions", }, "accessRules": [ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, @@ -132,7 +147,20 @@ TEMPLATE_ROLES = [ {"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True}, - ] + ], + }, + { + "roleLabel": "commcoach-admin", + "description": { + "en": "Communication Coach Admin - All UI and API actions; data scoped to own records", + "de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze", + "fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres", + }, + "accessRules": [ + {"context": "UI", "item": None, "view": True}, + {"context": "RESOURCE", "item": None, "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, + ], }, ] @@ -142,7 +170,7 @@ def getFeatureDefinition() -> Dict[str, Any]: "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON, - "autoCreateInstance": True, + "autoCreateInstance": False, } diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py index 85d85cf6..6be563b6 100644 --- a/modules/features/commcoach/tests/test_mainCommcoach.py +++ b/modules/features/commcoach/tests/test_mainCommcoach.py @@ -31,7 +31,7 @@ class TestFeatureDefinition: assert defn["code"] == "commcoach" assert "label" in defn assert "icon" in defn - assert defn["autoCreateInstance"] is True + assert defn["autoCreateInstance"] is False class TestRbacObjects: diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index d32b441f..bfe97a13 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [ # Template roles for this feature TEMPLATE_ROLES = [ + { + "roleLabel": "neutralization-viewer", + "description": { + "en": "Neutralization Viewer - View neutralization data (read-only)", + "de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)", + "fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, + { + "roleLabel": "neutralization-user", + "description": { + "en": "Neutralization User - Use neutralization tools and manage own data", + "de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten", + "fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, + {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + ], + }, { "roleLabel": "neutralization-admin", "description": { "en": "Neutralization Administrator - Full access to neutralization settings and data", "de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", - "fr": "Administrateur neutralisation - Accès complet aux paramètres et données" + "fr": "Administrateur neutralisation - Accès complet aux paramètres et données", }, "accessRules": [ - # Full UI access (all views including admin views) {"context": "UI", "item": None, "view": True}, - # Full DATA access {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, - ] + ], }, { "roleLabel": "neutralization-analyst", "description": { "en": "Neutralization Analyst - Analyze and process neutralization data", "de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", - "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation" + "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation", }, "accessRules": [ - # UI access to specific views - vollqualifizierte ObjectKeys {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, - # Group-level DATA access (read-only for sensitive config) {"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"}, - ] + ], }, ] diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 2ae2378b..dfe310d5 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [ # Template roles for this feature with AccessRules # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) TEMPLATE_ROLES = [ + { + "roleLabel": "realestate-viewer", + "description": { + "en": "Real Estate Viewer - View property information (read-only)", + "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)", + "fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, + { + "roleLabel": "realestate-user", + "description": { + "en": "Real Estate User - Create and manage own property records", + "de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten", + "fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, + ], + }, { "roleLabel": "realestate-admin", "description": { "en": "Real Estate Administrator - Full access to all property data and settings", "de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", - "fr": "Administrateur immobilier - Accès complet aux données et paramètres" + "fr": "Administrateur immobilier - Accès complet aux données et paramètres", }, "accessRules": [ - # Full UI access (all views including admin views) {"context": "UI", "item": None, "view": True}, - # Full DATA access {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, - # Admin resources {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True}, - ] + ], }, { "roleLabel": "realestate-manager", "description": { "en": "Real Estate Manager - Manage properties and tenants", "de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", - "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires" + "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires", }, "accessRules": [ - # UI access to map view {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, - # Group-level DATA access {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, - # Resource: create projects {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, - ] - }, - { - "roleLabel": "realestate-viewer", - "description": { - "en": "Real Estate Viewer - View property information", - "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen", - "fr": "Visualiseur immobilier - Consulter les informations immobilières" - }, - "accessRules": [ - # UI access to map view (read-only) - {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, - # Read-only DATA access (my records) - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - ] + ], }, ] diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 9be96393..4d6519d8 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.timeUtils import getIsoTimestamp from modules.shared.configuration import APP_CONFIG from .datamodelTeamsbot import ( @@ -104,13 +103,10 @@ class TeamsbotObjects: def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]: """Create a new session.""" - sessionData["creationDate"] = getIsoTimestamp() - sessionData["lastModified"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotSession, sessionData) def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update session fields.""" - updates["lastModified"] = getIsoTimestamp() return self.db.recordModify(TeamsbotSession, sessionId, updates) def deleteSession(self, sessionId: str) -> bool: @@ -149,7 +145,6 @@ class TeamsbotObjects: def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]: """Create a new transcript segment.""" - transcriptData["creationDate"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotTranscript, transcriptData) def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -180,7 +175,6 @@ class TeamsbotObjects: def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]: """Create a new bot response record.""" - responseData["creationDate"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotBotResponse, responseData) def _deleteResponsesBySession(self, sessionId: str) -> int: @@ -216,13 +210,10 @@ class TeamsbotObjects: def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]: """Create a new system bot account.""" - botData["creationDate"] = getIsoTimestamp() - botData["lastModified"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotSystemBot, botData) def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update a system bot account.""" - updates["lastModified"] = getIsoTimestamp() return self.db.recordModify(TeamsbotSystemBot, botId, updates) def deleteSystemBot(self, botId: str) -> bool: @@ -243,13 +234,10 @@ class TeamsbotObjects: def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: """Create user settings.""" - settingsData["creationDate"] = getIsoTimestamp() - settingsData["lastModified"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotUserSettings, settingsData) def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update user settings.""" - updates["lastModified"] = getIsoTimestamp() return self.db.recordModify(TeamsbotUserSettings, settingsId, updates) def deleteUserSettings(self, settingsId: str) -> bool: @@ -270,13 +258,10 @@ class TeamsbotObjects: def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]: """Create saved MS credentials.""" - data["creationDate"] = getIsoTimestamp() - data["lastModified"] = getIsoTimestamp() return self.db.recordCreate(TeamsbotUserAccount, data) def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update saved MS credentials.""" - updates["lastModified"] = getIsoTimestamp() return self.db.recordModify(TeamsbotUserAccount, accountId, updates) def deleteUserAccount(self, accountId: str) -> bool: diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index 97cc107e..afdce822 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -103,25 +103,35 @@ TEMPLATE_ROLES = [ {"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True}, ] }, + { + "roleLabel": "teamsbot-viewer", + "description": { + "en": "Teams Bot Viewer - View sessions and transcripts (read-only)", + "de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)", + "fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, { "roleLabel": "teamsbot-user", "description": { "en": "Teams Bot User - Can start/stop sessions and view transcripts", "de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", - "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions" + "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions", }, "accessRules": [ - # UI access to dashboard and sessions (not settings) {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, - # Own records only {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - # Start and stop sessions {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, - ] + ], }, ] @@ -132,7 +142,7 @@ def getFeatureDefinition() -> Dict[str, Any]: "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON, - "autoCreateInstance": True, + "autoCreateInstance": False, } diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 606da308..45824b1b 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -170,60 +170,81 @@ RESOURCE_OBJECTS = [ # Note: UI item=None means ALL views, specific items restrict to named views # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) TEMPLATE_ROLES = [ + { + "roleLabel": "trustee-viewer", + "description": { + "en": "Trustee Viewer - View trustee data (read-only)", + "de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)", + "fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ], + }, + { + "roleLabel": "trustee-user", + "description": { + "en": "Trustee User - Create and manage own trustee records", + "de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten", + "fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires", + }, + "accessRules": [ + {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + ], + }, { "roleLabel": "trustee-admin", "description": { "en": "Trustee Administrator - Full access to all trustee data and settings", "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", - "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" + "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires", }, "accessRules": [ - # Full UI access (all views including admin views) {"context": "UI", "item": None, "view": True}, - # Full DATA access {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, - # Admin resource: manage instance roles {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True}, - ] + ], }, { "roleLabel": "trustee-accountant", "description": { "en": "Trustee Accountant - Manage accounting and financial data", "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", - "fr": "Comptable fiduciaire - Gérer les données comptables et financières" + "fr": "Comptable fiduciaire - Gérer les données comptables et financières", }, "accessRules": [ - # UI access to main views (not admin views, not expense-import) - vollqualifizierte ObjectKeys {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.settings", "view": True}, - # Group-level DATA access {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, - # Accounting sync permission {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True}, - ] + ], }, { "roleLabel": "trustee-client", "description": { "en": "Trustee Client - View own accounting data and documents", "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", - "fr": "Client fiduciaire - Consulter ses propres données comptables et documents" + "fr": "Client fiduciaire - Consulter ses propres données comptables et documents", }, "accessRules": [ - # UI access to main views + expense-import - vollqualifizierte ObjectKeys {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, {"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True}, - # Own records only (MY level) {"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, - ] + ], }, ] diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index ffde890f..d980eb56 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1446,7 +1446,7 @@ class AppObjects: if not adminRoleId: raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role") - self.createUserMandate(userId, mandateId, roleIds=[adminRoleId]) + self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True) subscription = MandateSubscription( mandateId=mandateId, @@ -1454,8 +1454,10 @@ class AppObjects: status=SubscriptionStatusEnum.PENDING, ) if plan.trialDays: - pass # trialEndsAt set on ACTIVE transition - self.db.recordCreate(MandateSubscription, subscription.model_dump()) + pass # trialEndsAt set on ACTIVE/TRIALING transition + from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot + subInterface = _getSubRoot() + subInterface.createSubscription(subscription) featureInterface = getFeatureInterface(self.db) mainModules = loadFeatureMainModules() @@ -1513,50 +1515,58 @@ class AppObjects: """ Activate PENDING subscriptions for all mandates where this user is a member. Called on login — trial period begins NOW, not at registration. + Uses the subscription interface (poweron_billing) for all subscription operations. Returns number of activated subscriptions. """ from modules.datamodels.datamodelSubscription import ( - MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS, + SubscriptionStatusEnum, BUILTIN_PLANS, ) + from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot from datetime import datetime, timezone, timedelta activated = 0 + subInterface = _getSubRoot() + userMandates = self.db.getRecordset( UserMandate, recordFilter={"userId": userId, "enabled": True} ) for um in userMandates: mandateId = um.get("mandateId") - subs = self.db.getRecordset( - MandateSubscription, - recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value} - ) - for sub in subs: + allSubs = subInterface.listForMandate(mandateId) + pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value] + + for sub in pendingSubs: subId = sub.get("id") planKey = sub.get("planKey") plan = BUILTIN_PLANS.get(planKey) now = datetime.now(timezone.utc) - updateData = { - "status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value, + targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE + additionalData = { "currentPeriodStart": now.isoformat(), } if plan and plan.trialDays: trialEnd = now + timedelta(days=plan.trialDays) - updateData["trialEndsAt"] = trialEnd.isoformat() - updateData["currentPeriodEnd"] = trialEnd.isoformat() + additionalData["trialEndsAt"] = trialEnd.isoformat() + additionalData["currentPeriodEnd"] = trialEnd.isoformat() elif plan and plan.billingPeriod: from modules.datamodels.datamodelSubscription import BillingPeriodEnum if plan.billingPeriod == BillingPeriodEnum.MONTHLY: - updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat() + additionalData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat() elif plan.billingPeriod == BillingPeriodEnum.YEARLY: - updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat() + additionalData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat() try: - self.db.recordModify(MandateSubscription, subId, updateData) + subInterface.transitionStatus( + subId, + expectedFromStatus=SubscriptionStatusEnum.PENDING, + toStatus=targetStatus, + additionalData=additionalData, + ) activated += 1 - logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}") + logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {targetStatus.value}") except Exception as e: logger.error(f"Failed to activate subscription {subId}: {e}") @@ -1848,7 +1858,7 @@ class AppObjects: logger.error(f"Error getting UserMandates: {e}") return [] - def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate: + def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None, *, skipCapacityCheck: bool = False) -> UserMandate: """ Create a UserMandate record (add user to mandate). Also creates a billing audit account for the user if billing is configured. @@ -1859,6 +1869,8 @@ class AppObjects: userId: User ID mandateId: Mandate ID roleIds: List of role IDs to assign (at least one required) + skipCapacityCheck: If True, skip subscription capacity check (used during initial provisioning + when the subscription hasn't been created yet) Returns: Created UserMandate object @@ -1871,7 +1883,8 @@ class AppObjects: if existing: raise ValueError(f"User {userId} is already member of mandate {mandateId}") - self._checkSubscriptionCapacity(mandateId, "users", delta=1) + if not skipCapacityCheck: + self._checkSubscriptionCapacity(mandateId, "users", delta=1) userMandate = UserMandate( userId=userId, @@ -2551,6 +2564,18 @@ class AppObjects: logger.error(f"Error getting invitations for target username {targetUsername}: {e}") return [] + def getInvitationsByEmail(self, email: str) -> List[Invitation]: + """Get all invitations for a target email address (email-only invitations).""" + try: + records = self.db.getRecordset(Invitation, recordFilter={"email": email}) + result = [] + for record in records: + result.append(Invitation(**dict(record))) + return result + except Exception as e: + logger.error(f"Error getting invitations for email {email}: {e}") + return [] + # ============================================ # Additional Helper Methods # ============================================ diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index bb2dc5c9..948f8918 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -586,17 +586,6 @@ class BillingObjects: # Create transaction record (always on transaction.accountId for audit) transactionDict = transaction.model_dump(exclude_none=True) - ts = getUtcTimestamp() - uid = str(self.userId) if self.userId else None - if transactionDict.get("sysCreatedAt") is None: - transactionDict["sysCreatedAt"] = ts - if transactionDict.get("sysModifiedAt") is None: - transactionDict["sysModifiedAt"] = ts - if uid: - if transactionDict.get("sysCreatedBy") is None: - transactionDict["sysCreatedBy"] = uid - if transactionDict.get("sysModifiedBy") is None: - transactionDict["sysModifiedBy"] = uid created = self.db.recordCreate(BillingTransaction, transactionDict) # Update balance on the target account diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 0a16b734..2df85164 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -823,7 +823,7 @@ class ComponentObjects: mimeType=file["mimeType"], fileHash=file["fileHash"], fileSize=file["fileSize"], - creationDate=file["creationDate"] + sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"), ) def getMimeType(self, fileName: str) -> str: @@ -928,9 +928,11 @@ class ComponentObjects: fileItems = [] for file in files: try: - creationDate = file.get("creationDate") - if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0: - file["creationDate"] = getUtcTimestamp() + sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate") + if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0: + file["sysCreatedAt"] = getUtcTimestamp() + else: + file["sysCreatedAt"] = sysCreatedAt fileName = file.get("fileName") if not fileName or fileName == "None": @@ -977,20 +979,19 @@ class ComponentObjects: file = filteredFiles[0] try: - # Get creation date from record or use current time - creationDate = file.get("creationDate") - if not creationDate: - creationDate = getUtcTimestamp() - + sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate") + if not sysCreatedAt: + sysCreatedAt = getUtcTimestamp() + return FileItem( id=file.get("id"), mandateId=file.get("mandateId"), + featureInstanceId=file.get("featureInstanceId", ""), fileName=file.get("fileName"), mimeType=file.get("mimeType"), - workflowId=file.get("workflowId"), fileHash=file.get("fileHash"), fileSize=file.get("fileSize"), - creationDate=creationDate + sysCreatedAt=sysCreatedAt, ) except Exception as e: logger.error(f"Error converting file record: {str(e)}") diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index a1da658b..7cce66ca 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -920,30 +920,29 @@ def send_password_link( expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) try: - from modules.serviceHub import Services - services = Services(targetUser) - + from modules.routes.routeSecurityLocal import _buildAuthEmailHtml, _sendAuthEmail + emailSubject = "PowerOn - Passwort setzen" - emailBody = f""" -Hallo {targetUser.fullName or targetUser.username}, + emailHtml = _buildAuthEmailHtml( + greeting=f"Hallo {targetUser.fullName or targetUser.username}", + bodyLines=[ + "Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.", + "", + f"Ihr Benutzername: {targetUser.username}", + "", + "Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:", + ], + buttonText="Passwort setzen", + buttonUrl=magicLink, + footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.", + ) -Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert. - -Ihr Benutzername: {targetUser.username} - -Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen: -{magicLink} - -Dieser Link ist {expiryHours} Stunden gültig. - -Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator. -""" - - emailSent = services.messaging.sendEmailDirect( + emailSent = _sendAuthEmail( recipient=targetUser.email, subject=emailSubject, - message=emailBody, - userId=str(targetUser.id) + message="", + userId=str(targetUser.id), + htmlOverride=emailHtml, ) if not emailSent: diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index cb913137..ccefcc87 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -292,37 +292,24 @@ def create_invitation( emailConnector = ConnectorMessagingEmail() if instance_label: emailSubject = f"Einladung zur Feature-Instanz {instance_label}" - invite_text = f"der Feature-Instanz {instance_label} (Mandant: {mandateName}) beizutreten" + invite_desc = f"der Feature-Instanz «{instance_label}» (Mandant: {mandateName}) beizutreten" else: emailSubject = f"Einladung zu {mandateName}" - invite_text = f"dem Mandanten {mandateName} beizutreten" - emailBody = f""" - - -

Sie wurden eingeladen!

-

Hallo {display_name},

-

Sie wurden eingeladen, {invite_text}.

-

Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:

-

- - Einladung annehmen - -

-

- Oder kopieren Sie diesen Link in Ihren Browser:
- {inviteUrl} -

-

- Diese Einladung ist {data.expiresInHours} Stunden gültig. -

-
-

- Diese E-Mail wurde automatisch von PowerOn gesendet. -

- - - """ - + invite_desc = f"dem Mandanten «{mandateName}» beizutreten" + + from modules.routes.routeSecurityLocal import _buildAuthEmailHtml + emailBody = _buildAuthEmailHtml( + greeting=f"Hallo {display_name}", + bodyLines=[ + f"Sie wurden eingeladen, {invite_desc}.", + "", + "Klicken Sie auf die Schaltfläche, um die Einladung anzunehmen:", + ], + buttonText="Einladung annehmen", + buttonUrl=inviteUrl, + footerText=f"Diese Einladung ist {data.expiresInHours} Stunden gültig.", + ) + emailConnector.send( recipient=email_val, subject=emailSubject, @@ -376,6 +363,8 @@ def create_invitation( f"to {target_desc}, expires in {data.expiresInHours}h" ) + # Invitation extends PowerOnModel: recordCreate/_saveRecord set sysCreatedAt and sysCreatedBy automatically. + # API response uses createdAt/createdBy; map from the system fields (no separate createdAt column on model). return InvitationResponse( id=str(createdRecord.get("id")), token=str(createdRecord.get("token")), @@ -384,8 +373,8 @@ def create_invitation( roleIds=createdRecord.get("roleIds", []), targetUsername=createdRecord.get("targetUsername"), email=createdRecord.get("email"), - createdBy=str(createdRecord.get("createdBy")), - createdAt=createdRecord.get("createdAt"), + createdBy=str(createdRecord["sysCreatedBy"]), + createdAt=float(createdRecord["sysCreatedAt"]), expiresAt=createdRecord.get("expiresAt"), usedBy=createdRecord.get("usedBy"), usedAt=createdRecord.get("usedAt"), diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 11b6cb0f..9ec4fc38 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -26,36 +26,122 @@ from modules.shared.timeUtils import getUtcTimestamp logger = logging.getLogger(__name__) -def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool: +def _buildAuthEmailHtml( + greeting: str, + bodyLines: list, + buttonText: str = None, + buttonUrl: str = None, + footerText: str = None, +) -> str: + """Build a branded HTML email for authentication flows. + + Uses the same visual design as notifyMandateAdmins._renderHtmlEmail + (dark header, clean body, operator footer). + """ + import html as _html + + paragraphsHtml = "" + for line in bodyLines: + if line == "": + paragraphsHtml += '

 

\n' + else: + escaped = _html.escape(str(line)) + paragraphsHtml += f'

{escaped}

\n' + + buttonBlock = "" + if buttonText and buttonUrl: + buttonBlock = f'''
+ + {_html.escape(buttonText)} + +
+

+ {_html.escape(buttonUrl)} +

''' + + footerNote = "" + if footerText: + footerNote = f'

{_html.escape(footerText)}

\n' + + operatorLine = "" + try: + from modules.shared.configuration import APP_CONFIG + parts = [p for p in [ + APP_CONFIG.get("Operator_CompanyName", ""), + APP_CONFIG.get("Operator_Address", ""), + APP_CONFIG.get("Operator_VatNumber", ""), + ] if p] + if parts: + operatorLine = ( + f'

' + f'{_html.escape(" | ".join(parts))}

\n' + ) + except Exception: + pass + + return f''' + + + + + +
+ + + + + + + +
+

PowerOn

+
+

{_html.escape(greeting)}

+
+ {paragraphsHtml} + {buttonBlock} +
+ {footerNote} +
+

+ Diese E-Mail wurde automatisch von PowerOn versendet. +

+ {operatorLine} +
+
+ +''' + + +def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None, htmlOverride: str = None) -> bool: """ Send authentication-related email directly without requiring full Services initialization. Used for registration, password reset, and other auth flows. - + Args: recipient: Email address subject: Email subject - message: Plain text message (will be converted to HTML) + message: Plain text fallback (ignored when htmlOverride is given) userId: Optional user ID for logging - + htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml) + Returns: bool: True if email was sent successfully """ try: - import html from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.datamodels.datamodelMessaging import MessagingChannel - - # Convert plain text to simple HTML - escaped = html.escape(message) - escaped = escaped.replace('\n', '
\n') - htmlMessage = f""" - - - -{escaped} - -""" - + + htmlMessage = htmlOverride + if not htmlMessage: + import html + escaped = html.escape(message) + escaped = escaped.replace('\n', '
\n') + htmlMessage = f'{escaped}' + messagingInterface = getMessagingInterface() success = messagingInterface.send( channel=MessagingChannel.EMAIL, @@ -63,12 +149,12 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non subject=subject, message=htmlMessage ) - + if success: logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})") else: logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})") - + return success except Exception as e: logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True) @@ -88,15 +174,43 @@ router = APIRouter( ) def _ensureHomeMandate(rootInterface, user) -> None: - """Ensure user has a Home mandate. Creates 'Home {username}' if none exists.""" - userMandates = rootInterface.getUserMandates(str(user.id)) + """Ensure user has a Home mandate, but only if they have no mandate memberships + AND no pending invitations. + + Invited users should NOT get a Home mandate — they join existing mandates via + invitation acceptance and can create their own later via onboarding. + """ + userId = str(user.id) + userMandates = rootInterface.getUserMandates(userId) + + if userMandates: + for um in userMandates: + mandate = rootInterface.getMandate(um.mandateId) + if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem: + return + logger.debug(f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation") + return + + try: + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf + appIf = _getRootIf() + normalizedEmail = (user.email or "").strip().lower() if user.email else None + pendingByUsername = appIf.getInvitationsByTargetUsername(user.username) + pendingByEmail = appIf.getInvitationsByEmail(normalizedEmail) if normalizedEmail else [] + seenIds = set() + for inv in pendingByUsername + pendingByEmail: + if inv.id in seenIds: + continue + seenIds.add(inv.id) + if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1): + logger.info(f"User {user.username} has pending invitation(s) — skipping Home mandate creation") + return + except Exception as e: + logger.warning(f"Could not check pending invitations for {user.username}: {e}") + homeMandateName = f"Home {user.username}" - for um in userMandates: - mandate = rootInterface.getMandate(um.mandateId) - if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem: - return rootInterface._provisionMandateForUser( - userId=str(user.id), + userId=userId, mandateName=homeMandateName, planKey="TRIAL_7D", ) @@ -191,7 +305,14 @@ def login( # Save access token userInterface.saveAccessToken(token) - # Activate PENDING subscriptions on first login + # Ensure user has a Home mandate (created on first login if missing) + try: + _ensureHomeMandate(rootInterface, user) + except Exception as homeErr: + logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}") + + # Activate PENDING subscriptions on first login (runs AFTER _ensureHomeMandate + # so that a freshly provisioned Home mandate subscription is also activated) try: activatedCount = rootInterface._activatePendingSubscriptions(str(user.id)) if activatedCount > 0: @@ -199,12 +320,6 @@ def login( except Exception as subErr: logger.error(f"Error activating subscriptions on login: {subErr}") - # Ensure user has a Home mandate (created on first login if missing) - try: - _ensureHomeMandate(rootInterface, user) - except Exception as homeErr: - logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}") - # Log successful login (app log file + audit DB for traceability) logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id)) try: @@ -282,35 +397,28 @@ def register_user( ) -> Dict[str, Any]: """Register a new local user (magic link based - no password required). + Unified registration path: invited users skip Home mandate provisioning + (they join the inviting mandate instead). Non-invited users get a Home + mandate with TRIAL_7D. Company mandate creation is deferred to onboarding. + Args: userData: User data (username, email, fullName, language) frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend) + registrationType: Kept for backward compat but ignored (company mandates via onboarding) + companyName: Kept for backward compat but ignored """ try: - # Get gateway interface with root privileges since this is a public endpoint appInterface = getRootInterface() - - # Note: User registration does NOT require mandateId context - # Users are mandate-independent (Multi-Tenant Design) - # Mandate assignment happens via createUserMandate() after registration - - # Frontend URL is required - no fallback baseUrl = frontendUrl.rstrip("/") - - # Normalize email normalizedEmail = userData.email.lower().strip() if userData.email else None - # Note: Email can be shared across multiple users (different mandates) - # Username uniqueness is enforced in createUser() - that's the primary constraint - - # Create user with local authentication (no password - magic link based) user = appInterface.createUser( username=userData.username, - password=None, # No password - will be set via magic link + password=None, email=normalizedEmail, fullName=userData.fullName, language=userData.language, - enabled=True, # Users are enabled by default (can login after setting password) + enabled=True, authenticationAuthority=AuthAuthority.LOCAL ) @@ -320,35 +428,51 @@ def register_user( detail="Failed to register user" ) - # Provision Home mandate for every new user ("Home {username}") - provisionResult = None + # Check for pending invitations BEFORE provisioning. + # Search by both username AND email (email-only invitations have targetUsername=None). + hasPendingInvitations = False + validInvitations = [] try: - homeMandateName = f"Home {user.username}" - provisionResult = appInterface._provisionMandateForUser( - userId=str(user.id), - mandateName=homeMandateName, - planKey="TRIAL_7D", - ) - logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}") - except Exception as provErr: - logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}") + from modules.datamodels.datamodelInvitation import Invitation - # If company registration, also create a company mandate with the paid plan - if registrationType == "company": - if not companyName: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="companyName is required for company registration" - ) + currentTime = getUtcTimestamp() + pendingByUsername = appInterface.getInvitationsByTargetUsername(userData.username) + pendingByEmail = appInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else [] + + seenIds = set() + allPending = pendingByUsername + pendingByEmail + for invitation in allPending: + if invitation.id in seenIds: + continue + seenIds.add(invitation.id) + if (invitation.expiresAt or 0) < currentTime: + continue + if invitation.revokedAt: + continue + if (invitation.currentUses or 0) >= (invitation.maxUses or 1): + continue + validInvitations.append(invitation) + + hasPendingInvitations = len(validInvitations) > 0 + except Exception as invErr: + logger.warning(f"Failed to check pending invitations: {invErr}") + + # Only provision Home mandate if user has NO pending invitations. + # Invited users join existing mandates; they can create their own later via onboarding. + provisionResult = None + if not hasPendingInvitations: try: - companyResult = appInterface._provisionMandateForUser( + homeMandateName = f"Home {user.username}" + provisionResult = appInterface._provisionMandateForUser( userId=str(user.id), - mandateName=companyName, - planKey="STANDARD_MONTHLY", + mandateName=homeMandateName, + planKey="TRIAL_7D", ) - logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}") - except Exception as compErr: - logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}") + logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}") + except Exception as provErr: + logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}") + else: + logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)") # Generate reset token for password setup token, expires = appInterface.generateResetTokenAndExpiry() @@ -360,57 +484,43 @@ def register_user( expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) emailSubject = "PowerOn Registrierung - Passwort setzen" - emailBody = f"""Hallo {user.fullName or user.username}, + emailHtml = _buildAuthEmailHtml( + greeting=f"Hallo {user.fullName or user.username}", + bodyLines=[ + "Vielen Dank für Ihre Registrierung bei PowerOn.", + "", + f"Ihr Benutzername: {user.username}", + "", + "Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:", + ], + buttonText="Passwort setzen", + buttonUrl=magicLink, + footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.", + ) -Vielen Dank für Ihre Registrierung bei PowerOn. - -Ihr Benutzername: {user.username} - -Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen: -{magicLink} - -Dieser Link ist {expiryHours} Stunden gültig. - -Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" - emailSent = _sendAuthEmail( recipient=user.email, subject=emailSubject, - message=emailBody, - userId=str(user.id) + message="", + userId=str(user.id), + htmlOverride=emailHtml, ) if not emailSent: logger.warning(f"Failed to send registration email to {user.email}") except Exception as emailErr: logger.error(f"Error sending registration email: {str(emailErr)}") - # Don't fail registration if email fails - user can request reset later - # Check for pending invitations and create notifications - try: - from modules.datamodels.datamodelInvitation import Invitation - from modules.routes.routeNotifications import createInvitationNotification - from modules.datamodels.datamodelUam import Mandate - - currentTime = getUtcTimestamp() - pendingInvitations = appInterface.getInvitationsByTargetUsername(userData.username) - - for invitation in pendingInvitations: - # Skip expired, revoked, or fully used invitations - if (invitation.expiresAt or 0) < currentTime: - continue - if invitation.revokedAt: - continue - if (invitation.currentUses or 0) >= (invitation.maxUses or 1): - continue + # Create notifications for pending invitations + for invitation in validInvitations: + try: + from modules.routes.routeNotifications import createInvitationNotification - # Get mandate name for notification using interface method mandateId = invitation.mandateId mandate = appInterface.getMandate(mandateId) mandateName = (mandate.label or mandate.name) if mandate else "PowerOn" - # Get inviter name - inviterId = invitation.createdBy + inviterId = invitation.sysCreatedBy inviter = appInterface.getUser(inviterId) if inviterId else None inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn" @@ -421,16 +531,15 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" inviterName=inviterName ) logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}") - - except Exception as notifErr: - logger.warning(f"Failed to create notifications for pending invitations: {notifErr}") - # Don't fail registration if notification creation fails + except Exception as notifErr: + logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}") responseData = { "message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts." } if provisionResult: responseData["mandateId"] = provisionResult.get("mandateId") + responseData["hasInvitations"] = hasPendingInvitations return responseData except ValueError as e: @@ -676,24 +785,26 @@ def password_reset_request( # Send email using dedicated auth email function emailSubject = "PowerOn - Passwort zurücksetzen" - emailBody = f"""Hallo {user.fullName or user.username}, + emailHtml = _buildAuthEmailHtml( + greeting=f"Hallo {user.fullName or user.username}", + bodyLines=[ + "Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.", + "", + f"Benutzername: {user.username}", + "", + "Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:", + ], + buttonText="Passwort zurücksetzen", + buttonUrl=magicLink, + footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.", + ) -Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. - -Benutzername: {user.username} - -Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: -{magicLink} - -Dieser Link ist {expiryHours} Stunden gültig. - -Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.""" - emailSent = _sendAuthEmail( recipient=user.email, subject=emailSubject, - message=emailBody, - userId=str(user.id) + message="", + userId=str(user.id), + htmlOverride=emailHtml, ) if emailSent: @@ -725,24 +836,63 @@ def onboarding_provision( companyName: str = Body(None, embed=True), planKey: str = Body("TRIAL_7D", embed=True), ) -> Dict[str, Any]: - """Post-login onboarding: ensure Home mandate exists and optionally create a company mandate.""" + """Post-login onboarding: create a mandate for the user. + + Guard: user can only create a mandate if they are NOT already admin in any + non-system mandate. This prevents duplicate provisioning. + """ try: + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + from modules.datamodels.datamodelRbac import Role + appInterface = getRootInterface() + db = appInterface.db + userId = str(currentUser.id) - _ensureHomeMandate(appInterface, currentUser) + # Check if user already has admin role in a non-system mandate + userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True}) + hasAdminMandate = False + for um in userMandates: + mandateId = um.get("mandateId") + mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId}) + if mandate and mandate[0].get("isSystem"): + continue + umId = um.get("id") + umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId}) + for umRole in umRoles: + roleId = umRole.get("roleId") + roles = db.getRecordset(Role, recordFilter={"id": roleId}) + for role in roles: + if "admin" in (role.get("roleLabel") or "").lower(): + hasAdminMandate = True + break + if hasAdminMandate: + break + if hasAdminMandate: + break - result = None - if companyName and companyName.strip(): - if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"): - planKey = "STANDARD_MONTHLY" - result = appInterface._provisionMandateForUser( - userId=str(currentUser.id), - mandateName=companyName.strip(), - planKey=planKey, - ) + if hasAdminMandate: + logger.info(f"Onboarding: user {currentUser.username} already has admin mandate — skipping provisioning") + return { + "message": "User already has an admin mandate", + "mandateId": None, + "alreadyProvisioned": True, + } + + mandateName = (companyName.strip() if companyName and companyName.strip() + else f"Home {currentUser.username}") + + if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"): + planKey = "TRIAL_7D" + + result = appInterface._provisionMandateForUser( + userId=userId, + mandateName=mandateName, + planKey=planKey, + ) try: - activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id)) + activatedCount = appInterface._activatePendingSubscriptions(userId) if activatedCount > 0: logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding") except Exception as subErr: diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index 5c6f782a..0d6e68ec 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -136,8 +136,8 @@ def listUserMandates( ) -> List[Dict[str, Any]]: """ List mandates where the user can activate features (admin mandates). - If user has 0 admin mandates, auto-provisions a personal mandate so the - Store always has a clear mandate context. + Returns empty list if user has no admin mandates — the frontend handles + this via OnboardingAssistant/OnboardingWizard to create a mandate. """ try: rootInterface = getRootInterface() @@ -145,16 +145,6 @@ def listUserMandates( userId = str(context.user.id) adminMandateIds = _getUserAdminMandateIds(db, userId) - if not adminMandateIds: - homeMandateName = f"Home {context.user.username}" - provisionResult = rootInterface._provisionMandateForUser( - userId=userId, - mandateName=homeMandateName, - planKey="TRIAL_7D", - ) - adminMandateIds = [provisionResult["mandateId"]] - logger.info(f"Auto-provisioned personal mandate {adminMandateIds[0]} for user {userId} on Store access") - result = [] for mid in adminMandateIds: records = db.getRecordset(Mandate, recordFilter={"id": mid}) diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 40769fae..f3a74b1e 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -422,7 +422,7 @@ class ChatService: "size": fileItem.fileSize, "mimeType": fileItem.mimeType, "fileHash": fileItem.fileHash, - "creationDate": fileItem.creationDate, + "creationDate": fileItem.sysCreatedAt, "tags": getattr(fileItem, "tags", None), "folderId": getattr(fileItem, "folderId", None), "description": getattr(fileItem, "description", None), @@ -482,7 +482,7 @@ class ChatService: "fileName": fileItem.fileName, "mimeType": fileItem.mimeType, "fileSize": fileItem.fileSize, - "creationDate": fileItem.creationDate, + "creationDate": fileItem.sysCreatedAt, "tags": getattr(fileItem, "tags", None), "folderId": getattr(fileItem, "folderId", None), "description": getattr(fileItem, "description", None), @@ -524,7 +524,7 @@ class ChatService: mandateId=self._context.mandate_id or "", userId=self.user.id if self.user else "", ) - return self.interfaceDbComponent.db.recordCreate(DataSource, ds) + return self.interfaceDbApp.db.recordCreate(DataSource, ds) def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]: """List data sources, optionally filtered by feature instance.""" @@ -532,19 +532,19 @@ class ChatService: recordFilter = {} if featureInstanceId: recordFilter["featureInstanceId"] = featureInstanceId - return self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter=recordFilter) + return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter=recordFilter) def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]: """Get a single data source by ID.""" from modules.datamodels.datamodelDataSource import DataSource - results = self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter={"id": dataSourceId}) + results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId}) return results[0] if results else None def deleteDataSource(self, dataSourceId: str) -> bool: """Delete a data source.""" from modules.datamodels.datamodelDataSource import DataSource try: - self.interfaceDbComponent.db.recordDelete(DataSource, dataSourceId) + self.interfaceDbApp.db.recordDelete(DataSource, dataSourceId) return True except Exception as e: logger.error(f"Failed to delete DataSource {dataSourceId}: {e}") diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index 8fccd4e4..99da173e 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -346,7 +346,7 @@ class GenerationService: "size": file_item.fileSize, "mimeType": file_item.mimeType, "fileHash": getattr(file_item, 'fileHash', None), - "creationDate": getattr(file_item, 'creationDate', None) + "creationDate": getattr(file_item, 'sysCreatedAt', None) } return None except Exception as e: