fixed onboarding flow
This commit is contained in:
parent
e0a09ae6b1
commit
a787cdf6bf
19 changed files with 550 additions and 323 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -292,36 +292,23 @@ def create_invitation(
|
|||
emailConnector = ConnectorMessagingEmail()
|
||||
if 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:
|
||||
emailSubject = f"Einladung zu {mandateName}"
|
||||
invite_text = f"dem Mandanten <strong>{mandateName}</strong> beizutreten"
|
||||
emailBody = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h2>Sie wurden eingeladen!</h2>
|
||||
<p>Hallo <strong>{display_name}</strong>,</p>
|
||||
<p>Sie wurden eingeladen, {invite_text}.</p>
|
||||
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
|
||||
Einladung annehmen
|
||||
</a>
|
||||
</p>
|
||||
<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>
|
||||
"""
|
||||
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,
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,97 @@ 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 += '<p style="margin: 0 0 14px 0;"> </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.
|
||||
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:
|
||||
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', '<br>\n')
|
||||
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>"""
|
||||
htmlMessage = htmlOverride
|
||||
if not htmlMessage:
|
||||
import html
|
||||
escaped = html.escape(message)
|
||||
escaped = escaped.replace('\n', '<br>\n')
|
||||
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>'
|
||||
|
||||
messagingInterface = getMessagingInterface()
|
||||
success = messagingInterface.send(
|
||||
|
|
@ -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},
|
||||
|
||||
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."""
|
||||
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.",
|
||||
)
|
||||
|
||||
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
|
||||
# Create notifications for pending invitations
|
||||
for invitation in validInvitations:
|
||||
try:
|
||||
from modules.routes.routeNotifications import createInvitationNotification
|
||||
|
||||
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
|
||||
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},
|
||||
|
||||
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."""
|
||||
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.",
|
||||
)
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue