fixed onboarding flow

This commit is contained in:
ValueOn AG 2026-03-30 23:03:36 +02:00
parent e0a09ae6b1
commit a787cdf6bf
19 changed files with 550 additions and 323 deletions

View file

@ -31,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA
ALLOWED_TRANSITIONS = { ALLOWED_TRANSITIONS = {
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),

View file

@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap "autoCreateInstance": False,
} }

View file

@ -60,12 +60,25 @@ RESOURCE_OBJECTS = [
] ]
TEMPLATE_ROLES = [ 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", "roleLabel": "automation2-user",
"description": { "description": {
"en": "Automation2 User - Use automation2 flow builder", "en": "Automation2 User - Use automation2 flow builder",
"de": "Automation2 Benutzer - Flow-Builder nutzen", "de": "Automation2 Benutzer - Flow-Builder nutzen",
"fr": "Utilisateur Automation2 - Utiliser le flow builder" "fr": "Utilisateur Automation2 - Utiliser le flow builder",
}, },
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True}, {"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.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "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"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
] ],
}, },
{ {
"roleLabel": "automation2-admin", "roleLabel": "automation2-admin",
@ -188,7 +201,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -109,12 +109,27 @@ RESOURCE_OBJECTS = [
] ]
TEMPLATE_ROLES = [ 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", "roleLabel": "commcoach-user",
"description": { "description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions", "en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", "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": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"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.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "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, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -31,7 +31,7 @@ class TestFeatureDefinition:
assert defn["code"] == "commcoach" assert defn["code"] == "commcoach"
assert "label" in defn assert "label" in defn
assert "icon" in defn assert "icon" in defn
assert defn["autoCreateInstance"] is True assert defn["autoCreateInstance"] is False
class TestRbacObjects: class TestRbacObjects:

View file

@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [
# Template roles for this feature # Template roles for this feature
TEMPLATE_ROLES = [ 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", "roleLabel": "neutralization-admin",
"description": { "description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data", "en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", "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": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
] ],
}, },
{ {
"roleLabel": "neutralization-analyst", "roleLabel": "neutralization-analyst",
"description": { "description": {
"en": "Neutralization Analyst - Analyze and process neutralization data", "en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", "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": [ "accessRules": [
# UI access to specific views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "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"}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
] ],
}, },
] ]

View file

@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [
# Template roles for this feature with AccessRules # Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [ 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", "roleLabel": "realestate-admin",
"description": { "description": {
"en": "Real Estate Administrator - Full access to all property data and settings", "en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", "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": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"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.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
] ],
}, },
{ {
"roleLabel": "realestate-manager", "roleLabel": "realestate-manager",
"description": { "description": {
"en": "Real Estate Manager - Manage properties and tenants", "en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", "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": [ "accessRules": [
# UI access to map view
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"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"}, {"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}, {"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"},
]
}, },
] ]

View file

@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from .datamodelTeamsbot import ( from .datamodelTeamsbot import (
@ -104,13 +103,10 @@ class TeamsbotObjects:
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]: def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new session.""" """Create a new session."""
sessionData["creationDate"] = getIsoTimestamp()
sessionData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSession, sessionData) return self.db.recordCreate(TeamsbotSession, sessionData)
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update session fields.""" """Update session fields."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSession, sessionId, updates) return self.db.recordModify(TeamsbotSession, sessionId, updates)
def deleteSession(self, sessionId: str) -> bool: def deleteSession(self, sessionId: str) -> bool:
@ -149,7 +145,6 @@ class TeamsbotObjects:
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]: def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new transcript segment.""" """Create a new transcript segment."""
transcriptData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotTranscript, transcriptData) return self.db.recordCreate(TeamsbotTranscript, transcriptData)
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: 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]: def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new bot response record.""" """Create a new bot response record."""
responseData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotBotResponse, responseData) return self.db.recordCreate(TeamsbotBotResponse, responseData)
def _deleteResponsesBySession(self, sessionId: str) -> int: def _deleteResponsesBySession(self, sessionId: str) -> int:
@ -216,13 +210,10 @@ class TeamsbotObjects:
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]: def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new system bot account.""" """Create a new system bot account."""
botData["creationDate"] = getIsoTimestamp()
botData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSystemBot, botData) return self.db.recordCreate(TeamsbotSystemBot, botData)
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update a system bot account.""" """Update a system bot account."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSystemBot, botId, updates) return self.db.recordModify(TeamsbotSystemBot, botId, updates)
def deleteSystemBot(self, botId: str) -> bool: def deleteSystemBot(self, botId: str) -> bool:
@ -243,13 +234,10 @@ class TeamsbotObjects:
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
"""Create user settings.""" """Create user settings."""
settingsData["creationDate"] = getIsoTimestamp()
settingsData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserSettings, settingsData) return self.db.recordCreate(TeamsbotUserSettings, settingsData)
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update user settings.""" """Update user settings."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates) return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
def deleteUserSettings(self, settingsId: str) -> bool: def deleteUserSettings(self, settingsId: str) -> bool:
@ -270,13 +258,10 @@ class TeamsbotObjects:
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]: def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create saved MS credentials.""" """Create saved MS credentials."""
data["creationDate"] = getIsoTimestamp()
data["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserAccount, data) return self.db.recordCreate(TeamsbotUserAccount, data)
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update saved MS credentials.""" """Update saved MS credentials."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserAccount, accountId, updates) return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
def deleteUserAccount(self, accountId: str) -> bool: def deleteUserAccount(self, accountId: str) -> bool:

View file

@ -103,25 +103,35 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True}, {"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", "roleLabel": "teamsbot-user",
"description": { "description": {
"en": "Teams Bot User - Can start/stop sessions and view transcripts", "en": "Teams Bot User - Can start/stop sessions and view transcripts",
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", "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": [ "accessRules": [
# UI access to dashboard and sessions (not settings)
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "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.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.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"}, {"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.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
] ],
}, },
] ]
@ -132,7 +142,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -170,60 +170,81 @@ RESOURCE_OBJECTS = [
# Note: UI item=None means ALL views, specific items restrict to named views # Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [ 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", "roleLabel": "trustee-admin",
"description": { "description": {
"en": "Trustee Administrator - Full access to all trustee data and settings", "en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "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": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"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}, {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
] ],
}, },
{ {
"roleLabel": "trustee-accountant", "roleLabel": "trustee-accountant",
"description": { "description": {
"en": "Trustee Accountant - Manage accounting and financial data", "en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "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": [ "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.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "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"}, {"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.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
] ],
}, },
{ {
"roleLabel": "trustee-client", "roleLabel": "trustee-client",
"description": { "description": {
"en": "Trustee Client - View own accounting data and documents", "en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "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": [ "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.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "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.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"}, {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
] ],
}, },
] ]

View file

@ -1446,7 +1446,7 @@ class AppObjects:
if not adminRoleId: if not adminRoleId:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role") 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( subscription = MandateSubscription(
mandateId=mandateId, mandateId=mandateId,
@ -1454,8 +1454,10 @@ class AppObjects:
status=SubscriptionStatusEnum.PENDING, status=SubscriptionStatusEnum.PENDING,
) )
if plan.trialDays: if plan.trialDays:
pass # trialEndsAt set on ACTIVE transition pass # trialEndsAt set on ACTIVE/TRIALING transition
self.db.recordCreate(MandateSubscription, subscription.model_dump()) from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
featureInterface = getFeatureInterface(self.db) featureInterface = getFeatureInterface(self.db)
mainModules = loadFeatureMainModules() mainModules = loadFeatureMainModules()
@ -1513,50 +1515,58 @@ class AppObjects:
""" """
Activate PENDING subscriptions for all mandates where this user is a member. Activate PENDING subscriptions for all mandates where this user is a member.
Called on login trial period begins NOW, not at registration. 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. Returns number of activated subscriptions.
""" """
from modules.datamodels.datamodelSubscription import ( 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 from datetime import datetime, timezone, timedelta
activated = 0 activated = 0
subInterface = _getSubRoot()
userMandates = self.db.getRecordset( userMandates = self.db.getRecordset(
UserMandate, recordFilter={"userId": userId, "enabled": True} UserMandate, recordFilter={"userId": userId, "enabled": True}
) )
for um in userMandates: for um in userMandates:
mandateId = um.get("mandateId") mandateId = um.get("mandateId")
subs = self.db.getRecordset( allSubs = subInterface.listForMandate(mandateId)
MandateSubscription, pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value]
recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value}
) for sub in pendingSubs:
for sub in subs:
subId = sub.get("id") subId = sub.get("id")
planKey = sub.get("planKey") planKey = sub.get("planKey")
plan = BUILTIN_PLANS.get(planKey) plan = BUILTIN_PLANS.get(planKey)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
updateData = { targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE
"status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value, additionalData = {
"currentPeriodStart": now.isoformat(), "currentPeriodStart": now.isoformat(),
} }
if plan and plan.trialDays: if plan and plan.trialDays:
trialEnd = now + timedelta(days=plan.trialDays) trialEnd = now + timedelta(days=plan.trialDays)
updateData["trialEndsAt"] = trialEnd.isoformat() additionalData["trialEndsAt"] = trialEnd.isoformat()
updateData["currentPeriodEnd"] = trialEnd.isoformat() additionalData["currentPeriodEnd"] = trialEnd.isoformat()
elif plan and plan.billingPeriod: elif plan and plan.billingPeriod:
from modules.datamodels.datamodelSubscription import BillingPeriodEnum from modules.datamodels.datamodelSubscription import BillingPeriodEnum
if plan.billingPeriod == BillingPeriodEnum.MONTHLY: if plan.billingPeriod == BillingPeriodEnum.MONTHLY:
updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat() additionalData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
elif plan.billingPeriod == BillingPeriodEnum.YEARLY: elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat() additionalData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
try: try:
self.db.recordModify(MandateSubscription, subId, updateData) subInterface.transitionStatus(
subId,
expectedFromStatus=SubscriptionStatusEnum.PENDING,
toStatus=targetStatus,
additionalData=additionalData,
)
activated += 1 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: except Exception as e:
logger.error(f"Failed to activate subscription {subId}: {e}") logger.error(f"Failed to activate subscription {subId}: {e}")
@ -1848,7 +1858,7 @@ class AppObjects:
logger.error(f"Error getting UserMandates: {e}") logger.error(f"Error getting UserMandates: {e}")
return [] 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). Create a UserMandate record (add user to mandate).
Also creates a billing audit account for the user if billing is configured. Also creates a billing audit account for the user if billing is configured.
@ -1859,6 +1869,8 @@ class AppObjects:
userId: User ID userId: User ID
mandateId: Mandate ID mandateId: Mandate ID
roleIds: List of role IDs to assign (at least one required) 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: Returns:
Created UserMandate object Created UserMandate object
@ -1871,6 +1883,7 @@ class AppObjects:
if existing: if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}") raise ValueError(f"User {userId} is already member of mandate {mandateId}")
if not skipCapacityCheck:
self._checkSubscriptionCapacity(mandateId, "users", delta=1) self._checkSubscriptionCapacity(mandateId, "users", delta=1)
userMandate = UserMandate( userMandate = UserMandate(
@ -2551,6 +2564,18 @@ class AppObjects:
logger.error(f"Error getting invitations for target username {targetUsername}: {e}") logger.error(f"Error getting invitations for target username {targetUsername}: {e}")
return [] 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 # Additional Helper Methods
# ============================================ # ============================================

View file

@ -586,17 +586,6 @@ class BillingObjects:
# Create transaction record (always on transaction.accountId for audit) # Create transaction record (always on transaction.accountId for audit)
transactionDict = transaction.model_dump(exclude_none=True) 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) created = self.db.recordCreate(BillingTransaction, transactionDict)
# Update balance on the target account # Update balance on the target account

View file

@ -823,7 +823,7 @@ class ComponentObjects:
mimeType=file["mimeType"], mimeType=file["mimeType"],
fileHash=file["fileHash"], fileHash=file["fileHash"],
fileSize=file["fileSize"], fileSize=file["fileSize"],
creationDate=file["creationDate"] sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"),
) )
def getMimeType(self, fileName: str) -> str: def getMimeType(self, fileName: str) -> str:
@ -928,9 +928,11 @@ class ComponentObjects:
fileItems = [] fileItems = []
for file in files: for file in files:
try: try:
creationDate = file.get("creationDate") sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0: if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
file["creationDate"] = getUtcTimestamp() file["sysCreatedAt"] = getUtcTimestamp()
else:
file["sysCreatedAt"] = sysCreatedAt
fileName = file.get("fileName") fileName = file.get("fileName")
if not fileName or fileName == "None": if not fileName or fileName == "None":
@ -977,20 +979,19 @@ class ComponentObjects:
file = filteredFiles[0] file = filteredFiles[0]
try: try:
# Get creation date from record or use current time sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
creationDate = file.get("creationDate") if not sysCreatedAt:
if not creationDate: sysCreatedAt = getUtcTimestamp()
creationDate = getUtcTimestamp()
return FileItem( return FileItem(
id=file.get("id"), id=file.get("id"),
mandateId=file.get("mandateId"), mandateId=file.get("mandateId"),
featureInstanceId=file.get("featureInstanceId", ""),
fileName=file.get("fileName"), fileName=file.get("fileName"),
mimeType=file.get("mimeType"), mimeType=file.get("mimeType"),
workflowId=file.get("workflowId"),
fileHash=file.get("fileHash"), fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"), fileSize=file.get("fileSize"),
creationDate=creationDate sysCreatedAt=sysCreatedAt,
) )
except Exception as e: except Exception as e:
logger.error(f"Error converting file record: {str(e)}") logger.error(f"Error converting file record: {str(e)}")

View file

@ -920,30 +920,29 @@ def send_password_link(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try: try:
from modules.serviceHub import Services from modules.routes.routeSecurityLocal import _buildAuthEmailHtml, _sendAuthEmail
services = Services(targetUser)
emailSubject = "PowerOn - Passwort setzen" emailSubject = "PowerOn - Passwort setzen"
emailBody = f""" emailHtml = _buildAuthEmailHtml(
Hallo {targetUser.fullName or targetUser.username}, 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. emailSent = _sendAuthEmail(
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(
recipient=targetUser.email, recipient=targetUser.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message="",
userId=str(targetUser.id) userId=str(targetUser.id),
htmlOverride=emailHtml,
) )
if not emailSent: if not emailSent:

View file

@ -292,36 +292,23 @@ def create_invitation(
emailConnector = ConnectorMessagingEmail() emailConnector = ConnectorMessagingEmail()
if instance_label: if instance_label:
emailSubject = f"Einladung zur Feature-Instanz {instance_label}" emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
invite_text = f"der Feature-Instanz <strong>{instance_label}</strong> (Mandant: {mandateName}) beizutreten" invite_desc = f"der Feature-Instanz «{instance_label}» (Mandant: {mandateName}) beizutreten"
else: else:
emailSubject = f"Einladung zu {mandateName}" emailSubject = f"Einladung zu {mandateName}"
invite_text = f"dem Mandanten <strong>{mandateName}</strong> beizutreten" invite_desc = f"dem Mandanten «{mandateName}» beizutreten"
emailBody = f"""
<html> from modules.routes.routeSecurityLocal import _buildAuthEmailHtml
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> emailBody = _buildAuthEmailHtml(
<h2>Sie wurden eingeladen!</h2> greeting=f"Hallo {display_name}",
<p>Hallo <strong>{display_name}</strong>,</p> bodyLines=[
<p>Sie wurden eingeladen, {invite_text}.</p> f"Sie wurden eingeladen, {invite_desc}.",
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p> "",
<p style="margin: 20px 0;"> "Klicken Sie auf die Schaltfläche, um die Einladung anzunehmen:",
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;"> ],
Einladung annehmen buttonText="Einladung annehmen",
</a> buttonUrl=inviteUrl,
</p> footerText=f"Diese Einladung ist {data.expiresInHours} Stunden gültig.",
<p style="color: #666; font-size: 0.9em;"> )
Oder kopieren Sie diesen Link in Ihren Browser:<br>
<code>{inviteUrl}</code>
</p>
<p style="color: #666; font-size: 0.9em;">
Diese Einladung ist {data.expiresInHours} Stunden gültig.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 0.8em;">
Diese E-Mail wurde automatisch von PowerOn gesendet.
</p>
</body>
</html>
"""
emailConnector.send( emailConnector.send(
recipient=email_val, recipient=email_val,
@ -376,6 +363,8 @@ def create_invitation(
f"to {target_desc}, expires in {data.expiresInHours}h" 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( return InvitationResponse(
id=str(createdRecord.get("id")), id=str(createdRecord.get("id")),
token=str(createdRecord.get("token")), token=str(createdRecord.get("token")),
@ -384,8 +373,8 @@ def create_invitation(
roleIds=createdRecord.get("roleIds", []), roleIds=createdRecord.get("roleIds", []),
targetUsername=createdRecord.get("targetUsername"), targetUsername=createdRecord.get("targetUsername"),
email=createdRecord.get("email"), email=createdRecord.get("email"),
createdBy=str(createdRecord.get("createdBy")), createdBy=str(createdRecord["sysCreatedBy"]),
createdAt=createdRecord.get("createdAt"), createdAt=float(createdRecord["sysCreatedAt"]),
expiresAt=createdRecord.get("expiresAt"), expiresAt=createdRecord.get("expiresAt"),
usedBy=createdRecord.get("usedBy"), usedBy=createdRecord.get("usedBy"),
usedAt=createdRecord.get("usedAt"), usedAt=createdRecord.get("usedAt"),

View file

@ -26,7 +26,97 @@ from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__) 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 += '<p style="margin: 0 0 14px 0;">&nbsp;</p>\n'
else:
escaped = _html.escape(str(line))
paragraphsHtml += f'<p style="margin: 0 0 14px 0; color: #333333;">{escaped}</p>\n'
buttonBlock = ""
if buttonText and buttonUrl:
buttonBlock = f'''<div style="text-align: center; margin: 24px 0 8px 0;">
<a href="{_html.escape(buttonUrl)}"
style="display: inline-block; background-color: #2563eb; color: #ffffff;
font-size: 15px; font-weight: 600; text-decoration: none;
padding: 12px 32px; border-radius: 6px; mso-padding-alt: 0;">
{_html.escape(buttonText)}
</a>
</div>
<p style="margin: 8px 0 0 0; font-size: 12px; color: #9ca3af; word-break: break-all; text-align: center;">
{_html.escape(buttonUrl)}
</p>'''
footerNote = ""
if footerText:
footerNote = f'<p style="margin: 16px 0 0 0; font-size: 13px; color: #888888;">{_html.escape(footerText)}</p>\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'<p style="margin: 4px 0 0 0; font-size: 11px; color: #b0b0b0; text-align: center;">'
f'{_html.escape(" | ".join(parts))}</p>\n'
)
except Exception:
pass
return f'''<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f7; padding: 32px 16px;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background-color: #1a1a2e; padding: 24px 32px;">
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">PowerOn</h1>
</td></tr>
<!-- Body -->
<tr><td style="padding: 32px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; font-weight: 600; color: #1a1a2e;">{_html.escape(greeting)}</h2>
<div style="font-size: 15px; line-height: 1.6;">
{paragraphsHtml}
{buttonBlock}
</div>
{footerNote}
</td></tr>
<!-- Footer -->
<tr><td style="padding: 16px 32px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
Diese E-Mail wurde automatisch von PowerOn versendet.
</p>
{operatorLine}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
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. Send authentication-related email directly without requiring full Services initialization.
Used for registration, password reset, and other auth flows. Used for registration, password reset, and other auth flows.
@ -34,27 +124,23 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non
Args: Args:
recipient: Email address recipient: Email address
subject: Email subject 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 userId: Optional user ID for logging
htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml)
Returns: Returns:
bool: True if email was sent successfully bool: True if email was sent successfully
""" """
try: try:
import html
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.datamodels.datamodelMessaging import MessagingChannel from modules.datamodels.datamodelMessaging import MessagingChannel
# Convert plain text to simple HTML htmlMessage = htmlOverride
if not htmlMessage:
import html
escaped = html.escape(message) escaped = html.escape(message)
escaped = escaped.replace('\n', '<br>\n') escaped = escaped.replace('\n', '<br>\n')
htmlMessage = f"""<!DOCTYPE html> htmlMessage = f'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family: Arial, sans-serif; line-height: 1.6;">{escaped}</body></html>'
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped}
</body>
</html>"""
messagingInterface = getMessagingInterface() messagingInterface = getMessagingInterface()
success = messagingInterface.send( success = messagingInterface.send(
@ -88,15 +174,43 @@ router = APIRouter(
) )
def _ensureHomeMandate(rootInterface, user) -> None: def _ensureHomeMandate(rootInterface, user) -> None:
"""Ensure user has a Home mandate. Creates 'Home {username}' if none exists.""" """Ensure user has a Home mandate, but only if they have no mandate memberships
userMandates = rootInterface.getUserMandates(str(user.id)) AND no pending invitations.
homeMandateName = f"Home {user.username}"
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: for um in userMandates:
mandate = rootInterface.getMandate(um.mandateId) mandate = rootInterface.getMandate(um.mandateId)
if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem: if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
return 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}"
rootInterface._provisionMandateForUser( rootInterface._provisionMandateForUser(
userId=str(user.id), userId=userId,
mandateName=homeMandateName, mandateName=homeMandateName,
planKey="TRIAL_7D", planKey="TRIAL_7D",
) )
@ -191,7 +305,14 @@ def login(
# Save access token # Save access token
userInterface.saveAccessToken(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: try:
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id)) activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
if activatedCount > 0: if activatedCount > 0:
@ -199,12 +320,6 @@ def login(
except Exception as subErr: except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {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) # Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id)) logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try: try:
@ -282,35 +397,28 @@ def register_user(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Register a new local user (magic link based - no password required). """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: Args:
userData: User data (username, email, fullName, language) userData: User data (username, email, fullName, language)
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend) 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: try:
# Get gateway interface with root privileges since this is a public endpoint
appInterface = getRootInterface() 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("/") baseUrl = frontendUrl.rstrip("/")
# Normalize email
normalizedEmail = userData.email.lower().strip() if userData.email else None 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( user = appInterface.createUser(
username=userData.username, username=userData.username,
password=None, # No password - will be set via magic link password=None,
email=normalizedEmail, email=normalizedEmail,
fullName=userData.fullName, fullName=userData.fullName,
language=userData.language, language=userData.language,
enabled=True, # Users are enabled by default (can login after setting password) enabled=True,
authenticationAuthority=AuthAuthority.LOCAL authenticationAuthority=AuthAuthority.LOCAL
) )
@ -320,8 +428,39 @@ def register_user(
detail="Failed to register user" detail="Failed to register user"
) )
# Provision Home mandate for every new user ("Home {username}") # Check for pending invitations BEFORE provisioning.
# Search by both username AND email (email-only invitations have targetUsername=None).
hasPendingInvitations = False
validInvitations = []
try:
from modules.datamodels.datamodelInvitation import Invitation
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 provisionResult = None
if not hasPendingInvitations:
try: try:
homeMandateName = f"Home {user.username}" homeMandateName = f"Home {user.username}"
provisionResult = appInterface._provisionMandateForUser( provisionResult = appInterface._provisionMandateForUser(
@ -332,23 +471,8 @@ def register_user(
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}") logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
except Exception as provErr: except Exception as provErr:
logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}") logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
else:
# If company registration, also create a company mandate with the paid plan logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)")
if registrationType == "company":
if not companyName:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="companyName is required for company registration"
)
try:
companyResult = appInterface._provisionMandateForUser(
userId=str(user.id),
mandateName=companyName,
planKey="STANDARD_MONTHLY",
)
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}")
# Generate reset token for password setup # Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry() token, expires = appInterface.generateResetTokenAndExpiry()
@ -360,57 +484,43 @@ def register_user(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen" emailSubject = "PowerOn Registrierung - Passwort setzen"
emailBody = f"""Hallo {user.fullName or user.username}, emailHtml = _buildAuthEmailHtml(
greeting=f"Hallo {user.fullName or user.username}",
Vielen Dank für Ihre Registrierung bei PowerOn. bodyLines=[
"Vielen Dank für Ihre Registrierung bei PowerOn.",
Ihr Benutzername: {user.username} "",
f"Ihr Benutzername: {user.username}",
Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen: "",
{magicLink} "Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
],
Dieser Link ist {expiryHours} Stunden gültig. buttonText="Passwort setzen",
buttonUrl=magicLink,
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.",
)
emailSent = _sendAuthEmail( emailSent = _sendAuthEmail(
recipient=user.email, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message="",
userId=str(user.id) userId=str(user.id),
htmlOverride=emailHtml,
) )
if not emailSent: if not emailSent:
logger.warning(f"Failed to send registration email to {user.email}") logger.warning(f"Failed to send registration email to {user.email}")
except Exception as emailErr: except Exception as emailErr:
logger.error(f"Error sending registration email: {str(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 # Create notifications for pending invitations
for invitation in validInvitations:
try: try:
from modules.datamodels.datamodelInvitation import Invitation
from modules.routes.routeNotifications import createInvitationNotification 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
# Get mandate name for notification using interface method
mandateId = invitation.mandateId mandateId = invitation.mandateId
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn" mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
# Get inviter name inviterId = invitation.sysCreatedBy
inviterId = invitation.createdBy
inviter = appInterface.getUser(inviterId) if inviterId else None inviter = appInterface.getUser(inviterId) if inviterId else None
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn" 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 inviterName=inviterName
) )
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}") logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
except Exception as notifErr: except Exception as notifErr:
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}") logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}")
# Don't fail registration if notification creation fails
responseData = { responseData = {
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts." "message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
} }
if provisionResult: if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId") responseData["mandateId"] = provisionResult.get("mandateId")
responseData["hasInvitations"] = hasPendingInvitations
return responseData return responseData
except ValueError as e: except ValueError as e:
@ -676,24 +785,26 @@ def password_reset_request(
# Send email using dedicated auth email function # Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen" emailSubject = "PowerOn - Passwort zurücksetzen"
emailBody = f"""Hallo {user.fullName or user.username}, emailHtml = _buildAuthEmailHtml(
greeting=f"Hallo {user.fullName or user.username}",
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. bodyLines=[
"Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.",
Benutzername: {user.username} "",
f"Benutzername: {user.username}",
Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: "",
{magicLink} "Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:",
],
Dieser Link ist {expiryHours} Stunden gültig. buttonText="Passwort zurücksetzen",
buttonUrl=magicLink,
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.""" footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.",
)
emailSent = _sendAuthEmail( emailSent = _sendAuthEmail(
recipient=user.email, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message="",
userId=str(user.id) userId=str(user.id),
htmlOverride=emailHtml,
) )
if emailSent: if emailSent:
@ -725,24 +836,63 @@ def onboarding_provision(
companyName: str = Body(None, embed=True), companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True), planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]: ) -> 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: try:
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
appInterface = getRootInterface() 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
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 = None
if companyName and companyName.strip():
if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
planKey = "STANDARD_MONTHLY"
result = appInterface._provisionMandateForUser( result = appInterface._provisionMandateForUser(
userId=str(currentUser.id), userId=userId,
mandateName=companyName.strip(), mandateName=mandateName,
planKey=planKey, planKey=planKey,
) )
try: try:
activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id)) activatedCount = appInterface._activatePendingSubscriptions(userId)
if activatedCount > 0: if activatedCount > 0:
logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding") logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding")
except Exception as subErr: except Exception as subErr:

View file

@ -136,8 +136,8 @@ def listUserMandates(
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
List mandates where the user can activate features (admin mandates). List mandates where the user can activate features (admin mandates).
If user has 0 admin mandates, auto-provisions a personal mandate so the Returns empty list if user has no admin mandates the frontend handles
Store always has a clear mandate context. this via OnboardingAssistant/OnboardingWizard to create a mandate.
""" """
try: try:
rootInterface = getRootInterface() rootInterface = getRootInterface()
@ -145,16 +145,6 @@ def listUserMandates(
userId = str(context.user.id) userId = str(context.user.id)
adminMandateIds = _getUserAdminMandateIds(db, userId) 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 = [] result = []
for mid in adminMandateIds: for mid in adminMandateIds:
records = db.getRecordset(Mandate, recordFilter={"id": mid}) records = db.getRecordset(Mandate, recordFilter={"id": mid})

View file

@ -422,7 +422,7 @@ class ChatService:
"size": fileItem.fileSize, "size": fileItem.fileSize,
"mimeType": fileItem.mimeType, "mimeType": fileItem.mimeType,
"fileHash": fileItem.fileHash, "fileHash": fileItem.fileHash,
"creationDate": fileItem.creationDate, "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None), "tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None), "folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None), "description": getattr(fileItem, "description", None),
@ -482,7 +482,7 @@ class ChatService:
"fileName": fileItem.fileName, "fileName": fileItem.fileName,
"mimeType": fileItem.mimeType, "mimeType": fileItem.mimeType,
"fileSize": fileItem.fileSize, "fileSize": fileItem.fileSize,
"creationDate": fileItem.creationDate, "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None), "tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None), "folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None), "description": getattr(fileItem, "description", None),
@ -524,7 +524,7 @@ class ChatService:
mandateId=self._context.mandate_id or "", mandateId=self._context.mandate_id or "",
userId=self.user.id if self.user else "", 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]]: def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]:
"""List data sources, optionally filtered by feature instance.""" """List data sources, optionally filtered by feature instance."""
@ -532,19 +532,19 @@ class ChatService:
recordFilter = {} recordFilter = {}
if featureInstanceId: if featureInstanceId:
recordFilter["featureInstanceId"] = 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]]: def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]:
"""Get a single data source by ID.""" """Get a single data source by ID."""
from modules.datamodels.datamodelDataSource import DataSource 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 return results[0] if results else None
def deleteDataSource(self, dataSourceId: str) -> bool: def deleteDataSource(self, dataSourceId: str) -> bool:
"""Delete a data source.""" """Delete a data source."""
from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelDataSource import DataSource
try: try:
self.interfaceDbComponent.db.recordDelete(DataSource, dataSourceId) self.interfaceDbApp.db.recordDelete(DataSource, dataSourceId)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to delete DataSource {dataSourceId}: {e}") logger.error(f"Failed to delete DataSource {dataSourceId}: {e}")

View file

@ -346,7 +346,7 @@ class GenerationService:
"size": file_item.fileSize, "size": file_item.fileSize,
"mimeType": file_item.mimeType, "mimeType": file_item.mimeType,
"fileHash": getattr(file_item, 'fileHash', None), "fileHash": getattr(file_item, 'fileHash', None),
"creationDate": getattr(file_item, 'creationDate', None) "creationDate": getattr(file_item, 'sysCreatedAt', None)
} }
return None return None
except Exception as e: except Exception as e: