revised state machine for workflow backend and ui
This commit is contained in:
parent
a054d12d54
commit
bbea0ff115
19 changed files with 795 additions and 102 deletions
26
app.py
26
app.py
|
|
@ -313,32 +313,18 @@ async def lifespan(app: FastAPI):
|
||||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||||
registerAuditLogCleanupScheduler()
|
registerAuditLogCleanupScheduler()
|
||||||
|
|
||||||
# Ensure billing settings and accounts exist
|
# Ensure billing settings and accounts exist for all mandates
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||||
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
|
||||||
|
|
||||||
billingInterface = getBillingRootInterface()
|
billingInterface = getBillingRootInterface()
|
||||||
|
|
||||||
# Ensure root mandate has billing settings
|
# Step 1: Ensure all mandates have billing settings (creates defaults if missing)
|
||||||
rootMandate = rootInterface.getRootMandate()
|
settingsCreated = billingInterface.ensureAllMandateSettingsExist()
|
||||||
if rootMandate:
|
if settingsCreated > 0:
|
||||||
rootMandateId = rootMandate.get("id") if isinstance(rootMandate, dict) else getattr(rootMandate, "id", None)
|
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
|
||||||
if rootMandateId:
|
|
||||||
existingSettings = billingInterface.getSettings(rootMandateId)
|
|
||||||
if not existingSettings:
|
|
||||||
settings = BillingSettings(
|
|
||||||
mandateId=rootMandateId,
|
|
||||||
billingModel=BillingModelEnum.PREPAY_USER,
|
|
||||||
defaultUserCredit=10.0,
|
|
||||||
warningThresholdPercent=10.0,
|
|
||||||
blockOnZeroBalance=True,
|
|
||||||
notifyOnWarning=True
|
|
||||||
)
|
|
||||||
billingInterface.createSettings(settings)
|
|
||||||
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit")
|
|
||||||
|
|
||||||
# Efficient bulk check: Ensure all users have billing accounts (3 queries total)
|
# Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates)
|
||||||
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
||||||
if accountsCreated > 0:
|
if accountsCreated > 0:
|
||||||
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import uuid
|
||||||
|
|
||||||
class ChatStat(BaseModel):
|
class ChatStat(BaseModel):
|
||||||
"""Statistics for chat operations. User-owned, no mandate context."""
|
"""Statistics for chat operations. User-owned, no mandate context."""
|
||||||
|
model_config = {"populate_by_name": True, "extra": "allow"} # Allow DB system fields
|
||||||
|
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
)
|
)
|
||||||
|
|
@ -41,7 +43,7 @@ registerModelLabels(
|
||||||
"errorCount": {"en": "Error Count", "fr": "Nombre d'erreurs"},
|
"errorCount": {"en": "Error Count", "fr": "Nombre d'erreurs"},
|
||||||
"process": {"en": "Process", "fr": "Processus"},
|
"process": {"en": "Process", "fr": "Processus"},
|
||||||
"engine": {"en": "Engine", "fr": "Moteur"},
|
"engine": {"en": "Engine", "fr": "Moteur"},
|
||||||
"priceCHF": {"en": "Price USD", "fr": "Prix USD"},
|
"priceCHF": {"en": "Price CHF", "fr": "Prix CHF"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ class UserConnection(BaseModel):
|
||||||
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
||||||
]})
|
]})
|
||||||
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
grantedScopes: Optional[List[str]] = Field(None, description="OAuth scopes granted for this connection", json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@computed_field
|
@computed_field
|
||||||
|
|
@ -146,6 +147,7 @@ registerModelLabels(
|
||||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
||||||
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
|
"grantedScopes": {"en": "Granted Scopes", "de": "Gewährte Berechtigungen", "fr": "Autorisations accordées"},
|
||||||
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
|
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
|
||||||
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
|
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1116,7 +1116,7 @@ class ChatObjects:
|
||||||
|
|
||||||
# Emit message event for streaming (if event manager is available)
|
# Emit message event for streaming (if event manager is available)
|
||||||
try:
|
try:
|
||||||
from modules.features.chatbot.eventManager import get_event_manager
|
from modules.features.chatbot.eventManager import get_event_manager # type: ignore
|
||||||
event_manager = get_event_manager()
|
event_manager = get_event_manager()
|
||||||
message_timestamp = parseTimestamp(chat_message.publishedAt, default=getUtcTimestamp())
|
message_timestamp = parseTimestamp(chat_message.publishedAt, default=getUtcTimestamp())
|
||||||
# Emit message event in exact chatData format: {type, createdAt, item}
|
# Emit message event in exact chatData format: {type, createdAt, item}
|
||||||
|
|
@ -1514,7 +1514,7 @@ class ChatObjects:
|
||||||
# Only emit events for chatbot workflows, not for automation or dynamic workflows
|
# Only emit events for chatbot workflows, not for automation or dynamic workflows
|
||||||
if workflow.workflowMode == WorkflowModeEnum.WORKFLOW_CHATBOT:
|
if workflow.workflowMode == WorkflowModeEnum.WORKFLOW_CHATBOT:
|
||||||
try:
|
try:
|
||||||
from modules.features.chatbot.eventManager import get_event_manager
|
from modules.features.chatbot.eventManager import get_event_manager # type: ignore
|
||||||
event_manager = get_event_manager()
|
event_manager = get_event_manager()
|
||||||
log_timestamp = parseTimestamp(createdLog.get("timestamp"), default=getUtcTimestamp())
|
log_timestamp = parseTimestamp(createdLog.get("timestamp"), default=getUtcTimestamp())
|
||||||
# Emit log event in exact chatData format: {type, createdAt, item}
|
# Emit log event in exact chatData format: {type, createdAt, item}
|
||||||
|
|
@ -1563,8 +1563,8 @@ class ChatObjects:
|
||||||
if not stats:
|
if not stats:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Return all stats records sorted by creation time
|
# Return all stats records sorted by _createdAt (system field from DB)
|
||||||
stats.sort(key=lambda x: x.get("created_at", ""))
|
stats.sort(key=lambda x: x.get("_createdAt", 0))
|
||||||
# Ensure mandateId and featureInstanceId are set for each stat
|
# Ensure mandateId and featureInstanceId are set for each stat
|
||||||
return [ChatStat(**{**stat, "mandateId": stat.get("mandateId") or self.mandateId or "", "featureInstanceId": stat.get("featureInstanceId") or self.featureInstanceId or ""}) for stat in stats]
|
return [ChatStat(**{**stat, "mandateId": stat.get("mandateId") or self.mandateId or "", "featureInstanceId": stat.get("featureInstanceId") or self.featureInstanceId or ""}) for stat in stats]
|
||||||
|
|
||||||
|
|
@ -1680,11 +1680,12 @@ class ChatObjects:
|
||||||
"item": chatLog
|
"item": chatLog
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get stats list
|
# Get stats - ChatStat model now supports _createdAt via extra="allow"
|
||||||
stats = self.getStats(workflowId)
|
stats = self.getStats(workflowId)
|
||||||
for stat in stats:
|
for stat in stats:
|
||||||
# Apply timestamp filtering in Python
|
# Apply timestamp filtering in Python
|
||||||
stat_timestamp = stat.createdAt if hasattr(stat, 'createdAt') else getUtcTimestamp()
|
# Use _createdAt (system field from DB, preserved via model_config extra="allow")
|
||||||
|
stat_timestamp = getattr(stat, '_createdAt', None) or getUtcTimestamp()
|
||||||
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
|
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ _gatewayInterfaces = {}
|
||||||
# Root interface instance
|
# Root interface instance
|
||||||
_rootAppObjects = None
|
_rootAppObjects = None
|
||||||
|
|
||||||
|
# Bootstrap completion flag - ensures bootstrap runs only ONCE per application lifecycle
|
||||||
|
_bootstrapCompleted = False
|
||||||
|
|
||||||
# Password-Hashing
|
# Password-Hashing
|
||||||
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
|
||||||
|
|
@ -200,8 +203,28 @@ class AppObjects:
|
||||||
return simpleFields, objectFields
|
return simpleFields, objectFields
|
||||||
|
|
||||||
def _initRecords(self):
|
def _initRecords(self):
|
||||||
"""Initialize standard records if they don't exist."""
|
"""Initialize standard records if they don't exist.
|
||||||
initBootstrap(self.db)
|
|
||||||
|
Uses a global flag to ensure bootstrap only runs ONCE per application lifecycle.
|
||||||
|
The flag is set BEFORE calling bootstrap to prevent recursive calls during bootstrap.
|
||||||
|
"""
|
||||||
|
global _bootstrapCompleted
|
||||||
|
|
||||||
|
if _bootstrapCompleted:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set flag BEFORE bootstrap to prevent recursive calls during bootstrap
|
||||||
|
_bootstrapCompleted = True
|
||||||
|
logger.info("Starting bootstrap (will only run once)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
initBootstrap(self.db)
|
||||||
|
logger.info("Bootstrap completed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
# Reset flag on failure so bootstrap can be retried
|
||||||
|
_bootstrapCompleted = False
|
||||||
|
logger.error(f"Bootstrap failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def checkRbacPermission(
|
def checkRbacPermission(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import uuid
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User, Mandate
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
from modules.datamodels.datamodelBilling import (
|
from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
|
|
@ -360,6 +361,60 @@ class BillingObjects:
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
def ensureAllMandateSettingsExist(self) -> int:
|
||||||
|
"""
|
||||||
|
Efficiently ensure all mandates have billing settings.
|
||||||
|
Creates default settings (PREPAY_USER) for mandates without settings.
|
||||||
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of settings created
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
settingsCreated = 0
|
||||||
|
|
||||||
|
# Step 1: Get all existing billing settings in one query (from billing DB)
|
||||||
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
|
existingMandateIds = set(s.get("mandateId") for s in allSettings if s.get("mandateId"))
|
||||||
|
|
||||||
|
# Step 2: Get all mandates from APP database (separate connection)
|
||||||
|
appDb = DatabaseConnector(
|
||||||
|
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
|
||||||
|
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
|
||||||
|
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
|
||||||
|
dbUser=APP_CONFIG.get('DB_USER'),
|
||||||
|
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
|
||||||
|
)
|
||||||
|
allMandates = appDb.getRecordset(Mandate, recordFilter={"enabled": True})
|
||||||
|
|
||||||
|
# Step 3: Create settings for mandates that don't have them
|
||||||
|
for mandate in allMandates:
|
||||||
|
mandateId = mandate.get("id")
|
||||||
|
if not mandateId or mandateId in existingMandateIds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create default billing settings
|
||||||
|
settings = BillingSettings(
|
||||||
|
mandateId=mandateId,
|
||||||
|
billingModel=BillingModelEnum.PREPAY_USER,
|
||||||
|
defaultUserCredit=10.0,
|
||||||
|
warningThresholdPercent=10.0,
|
||||||
|
blockOnZeroBalance=True,
|
||||||
|
notifyOnWarning=True
|
||||||
|
)
|
||||||
|
self.createSettings(settings)
|
||||||
|
existingMandateIds.add(mandateId) # Track newly created
|
||||||
|
settingsCreated += 1
|
||||||
|
|
||||||
|
if settingsCreated > 0:
|
||||||
|
logger.info(f"Created {settingsCreated} missing billing settings for mandates")
|
||||||
|
|
||||||
|
return settingsCreated
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ensuring mandate settings exist: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
def ensureAllUserAccountsExist(self) -> int:
|
def ensureAllUserAccountsExist(self) -> int:
|
||||||
"""
|
"""
|
||||||
Efficiently ensure all users across all mandates have billing accounts.
|
Efficiently ensure all users across all mandates have billing accounts.
|
||||||
|
|
@ -368,10 +423,7 @@ class BillingObjects:
|
||||||
Returns:
|
Returns:
|
||||||
Number of accounts created
|
Number of accounts created
|
||||||
"""
|
"""
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
appInterface = getAppRootInterface()
|
|
||||||
accountsCreated = 0
|
accountsCreated = 0
|
||||||
|
|
||||||
# Step 1: Get all billing settings in one query (only PREPAY_USER mandates need user accounts)
|
# Step 1: Get all billing settings in one query (only PREPAY_USER mandates need user accounts)
|
||||||
|
|
@ -385,7 +437,7 @@ class BillingObjects:
|
||||||
logger.debug("No PREPAY_USER mandates found, skipping account check")
|
logger.debug("No PREPAY_USER mandates found, skipping account check")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Step 2: Get all existing USER accounts in one query
|
# Step 2: Get all existing USER accounts in one query (from billing DB)
|
||||||
allAccounts = self.db.getRecordset(
|
allAccounts = self.db.getRecordset(
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
recordFilter={"accountType": AccountTypeEnum.USER.value}
|
recordFilter={"accountType": AccountTypeEnum.USER.value}
|
||||||
|
|
@ -396,9 +448,16 @@ class BillingObjects:
|
||||||
key = (acc.get("mandateId"), acc.get("userId"))
|
key = (acc.get("mandateId"), acc.get("userId"))
|
||||||
existingAccountKeys.add(key)
|
existingAccountKeys.add(key)
|
||||||
|
|
||||||
# Step 3: Get all user-mandate combinations in one query
|
# Step 3: Get all user-mandate combinations from APP database (separate connection)
|
||||||
allUserMandates = appInterface.db.getRecordset(
|
appDb = DatabaseConnector(
|
||||||
appInterface.db.getModel("UserMandate"),
|
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
|
||||||
|
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
|
||||||
|
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
|
||||||
|
dbUser=APP_CONFIG.get('DB_USER'),
|
||||||
|
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
|
||||||
|
)
|
||||||
|
allUserMandates = appDb.getRecordset(
|
||||||
|
UserMandate,
|
||||||
recordFilter={"enabled": True}
|
recordFilter={"enabled": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -855,3 +914,338 @@ class BillingObjects:
|
||||||
logger.error(f"Error getting balances for user: {e}")
|
logger.error(f"Error getting balances for user: {e}")
|
||||||
|
|
||||||
return balances
|
return balances
|
||||||
|
|
||||||
|
def getTransactionsForUser(self, userId: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all transactions for a user across all mandates they belong to.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
allTransactions = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
userMandates = appInterface.getUserMandates(userId)
|
||||||
|
|
||||||
|
for um in userMandates:
|
||||||
|
# Handle both Pydantic models and dicts
|
||||||
|
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if not mandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Only include mandates with billing settings
|
||||||
|
settings = self.getSettings(mandateId)
|
||||||
|
if not settings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get transactions for this mandate
|
||||||
|
transactions = self.getTransactionsByMandate(mandateId, limit=limit)
|
||||||
|
|
||||||
|
# Add mandate context to each transaction
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
mandateName = ""
|
||||||
|
if mandate:
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
|
||||||
|
for t in transactions:
|
||||||
|
t["mandateId"] = mandateId
|
||||||
|
t["mandateName"] = mandateName
|
||||||
|
allTransactions.append(t)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting transactions for user: {e}")
|
||||||
|
|
||||||
|
# Sort by creation date descending and limit
|
||||||
|
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||||
|
return allTransactions[:limit]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Mandate View Operations (Admin-Level)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getMandateBalances(self, mandateIds: List[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get mandate-level balances.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of mandate balance dicts
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
balances = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
|
||||||
|
# Get settings for filtering
|
||||||
|
if mandateIds:
|
||||||
|
allSettings = [self.getSettings(mId) for mId in mandateIds]
|
||||||
|
allSettings = [s for s in allSettings if s]
|
||||||
|
else:
|
||||||
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
|
|
||||||
|
for settings in allSettings:
|
||||||
|
mandateId = settings.get("mandateId")
|
||||||
|
if not mandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# Get mandate info
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
mandateName = ""
|
||||||
|
if mandate:
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
|
||||||
|
# For PREPAY_MANDATE, get the mandate account balance
|
||||||
|
# For PREPAY_USER, aggregate all user balances
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
|
account = self.getMandateAccount(mandateId)
|
||||||
|
totalBalance = account.get("balance", 0.0) if account else 0.0
|
||||||
|
userCount = 0
|
||||||
|
elif billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
# Get all user accounts for this mandate
|
||||||
|
userAccounts = self.db.getRecordset(
|
||||||
|
BillingAccount,
|
||||||
|
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
||||||
|
)
|
||||||
|
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
||||||
|
userCount = len(userAccounts)
|
||||||
|
else:
|
||||||
|
totalBalance = 0.0
|
||||||
|
userCount = 0
|
||||||
|
|
||||||
|
balances.append({
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"mandateName": mandateName,
|
||||||
|
"billingModel": billingModel.value,
|
||||||
|
"totalBalance": totalBalance,
|
||||||
|
"userCount": userCount,
|
||||||
|
"defaultUserCredit": settings.get("defaultUserCredit", 0.0),
|
||||||
|
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
||||||
|
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mandate balances: {e}")
|
||||||
|
|
||||||
|
return balances
|
||||||
|
|
||||||
|
def getMandateTransactions(self, mandateIds: List[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all transactions for specified mandates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts with mandate context
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
allTransactions = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
|
||||||
|
# Determine which mandates to query
|
||||||
|
if mandateIds:
|
||||||
|
targetMandateIds = mandateIds
|
||||||
|
else:
|
||||||
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
|
targetMandateIds = [s.get("mandateId") for s in allSettings if s.get("mandateId")]
|
||||||
|
|
||||||
|
for mandateId in targetMandateIds:
|
||||||
|
transactions = self.getTransactionsByMandate(mandateId, limit=limit)
|
||||||
|
|
||||||
|
# Get mandate name
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
mandateName = ""
|
||||||
|
if mandate:
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
|
||||||
|
for t in transactions:
|
||||||
|
t["mandateId"] = mandateId
|
||||||
|
t["mandateName"] = mandateName
|
||||||
|
allTransactions.append(t)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mandate transactions: {e}")
|
||||||
|
|
||||||
|
# Sort by creation date descending and limit
|
||||||
|
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||||
|
return allTransactions[:limit]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# User View Operations (User-Level with RBAC)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getUserBalancesForMandates(self, mandateIds: List[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all user-level balances for specified mandates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user balance dicts with mandate and user context
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
balances = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
|
||||||
|
# Get all user accounts
|
||||||
|
accountFilter = {"accountType": AccountTypeEnum.USER.value}
|
||||||
|
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
|
||||||
|
|
||||||
|
# Filter by mandate if specified
|
||||||
|
if mandateIds:
|
||||||
|
allAccounts = [acc for acc in allAccounts if acc.get("mandateId") in mandateIds]
|
||||||
|
|
||||||
|
# Get all relevant settings in one query
|
||||||
|
settingsMap = {}
|
||||||
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
|
for s in allSettings:
|
||||||
|
settingsMap[s.get("mandateId")] = s
|
||||||
|
|
||||||
|
# Get user info efficiently
|
||||||
|
userIds = list(set(acc.get("userId") for acc in allAccounts if acc.get("userId")))
|
||||||
|
userMap = {}
|
||||||
|
for userId in userIds:
|
||||||
|
user = appInterface.getUser(userId)
|
||||||
|
if user:
|
||||||
|
displayName = getattr(user, 'displayName', None) or (user.get("displayName") if isinstance(user, dict) else None)
|
||||||
|
email = getattr(user, 'email', None) or (user.get("email") if isinstance(user, dict) else None)
|
||||||
|
userMap[userId] = displayName or email or userId
|
||||||
|
|
||||||
|
# Get mandate info efficiently
|
||||||
|
mandateMap = {}
|
||||||
|
mandateIdList = list(set(acc.get("mandateId") for acc in allAccounts if acc.get("mandateId")))
|
||||||
|
for mandateId in mandateIdList:
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
if mandate:
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
mandateMap[mandateId] = mandateName
|
||||||
|
|
||||||
|
for account in allAccounts:
|
||||||
|
mandateId = account.get("mandateId")
|
||||||
|
userId = account.get("userId")
|
||||||
|
|
||||||
|
if not mandateId or not userId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
settings = settingsMap.get(mandateId)
|
||||||
|
if not settings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
balance = account.get("balance", 0.0)
|
||||||
|
warningThreshold = account.get("warningThreshold", 0.0)
|
||||||
|
|
||||||
|
balances.append({
|
||||||
|
"accountId": account.get("id"),
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"mandateName": mandateMap.get(mandateId, ""),
|
||||||
|
"userId": userId,
|
||||||
|
"userName": userMap.get(userId, userId),
|
||||||
|
"balance": balance,
|
||||||
|
"warningThreshold": warningThreshold,
|
||||||
|
"isWarning": balance <= warningThreshold,
|
||||||
|
"enabled": account.get("enabled", True)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user balances for mandates: {e}")
|
||||||
|
|
||||||
|
return balances
|
||||||
|
|
||||||
|
def getUserTransactionsForMandates(self, mandateIds: List[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all user-level transactions for specified mandates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts with mandate and user context
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
allTransactions = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
|
||||||
|
# Get all user accounts
|
||||||
|
accountFilter = {"accountType": AccountTypeEnum.USER.value}
|
||||||
|
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
|
||||||
|
|
||||||
|
# Filter by mandate if specified
|
||||||
|
if mandateIds:
|
||||||
|
allAccounts = [acc for acc in allAccounts if acc.get("mandateId") in mandateIds]
|
||||||
|
|
||||||
|
# Build account to user/mandate mapping
|
||||||
|
accountMap = {}
|
||||||
|
for acc in allAccounts:
|
||||||
|
accountMap[acc.get("id")] = {
|
||||||
|
"mandateId": acc.get("mandateId"),
|
||||||
|
"userId": acc.get("userId")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get user info efficiently
|
||||||
|
userIds = list(set(acc.get("userId") for acc in allAccounts if acc.get("userId")))
|
||||||
|
userMap = {}
|
||||||
|
for userId in userIds:
|
||||||
|
user = appInterface.getUser(userId)
|
||||||
|
if user:
|
||||||
|
displayName = getattr(user, 'displayName', None) or (user.get("displayName") if isinstance(user, dict) else None)
|
||||||
|
email = getattr(user, 'email', None) or (user.get("email") if isinstance(user, dict) else None)
|
||||||
|
userMap[userId] = displayName or email or userId
|
||||||
|
|
||||||
|
# Get mandate info efficiently
|
||||||
|
mandateMap = {}
|
||||||
|
mandateIdList = list(set(acc.get("mandateId") for acc in allAccounts if acc.get("mandateId")))
|
||||||
|
for mandateId in mandateIdList:
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
if mandate:
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
mandateMap[mandateId] = mandateName
|
||||||
|
|
||||||
|
# Get transactions for all accounts
|
||||||
|
for account in allAccounts:
|
||||||
|
accountId = account.get("id")
|
||||||
|
if not accountId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
transactions = self.getTransactions(accountId, limit=limit)
|
||||||
|
accountInfo = accountMap.get(accountId, {})
|
||||||
|
mandateId = accountInfo.get("mandateId")
|
||||||
|
userId = accountInfo.get("userId")
|
||||||
|
|
||||||
|
for t in transactions:
|
||||||
|
t["mandateId"] = mandateId
|
||||||
|
t["mandateName"] = mandateMap.get(mandateId, "")
|
||||||
|
t["userId"] = userId
|
||||||
|
t["userName"] = userMap.get(userId, userId)
|
||||||
|
allTransactions.append(t)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user transactions for mandates: {e}")
|
||||||
|
|
||||||
|
# Sort by creation date descending and limit
|
||||||
|
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||||
|
return allTransactions[:limit]
|
||||||
|
|
|
||||||
|
|
@ -1634,11 +1634,12 @@ class ChatObjects:
|
||||||
"item": chatLog
|
"item": chatLog
|
||||||
})
|
})
|
||||||
|
|
||||||
# Get stats list
|
# Get stats - ChatStat model supports _createdAt via model_config extra="allow"
|
||||||
stats = self.getStats(workflowId)
|
stats = self.getStats(workflowId)
|
||||||
for stat in stats:
|
for stat in stats:
|
||||||
# Apply timestamp filtering in Python
|
# Apply timestamp filtering in Python
|
||||||
stat_timestamp = stat.createdAt if hasattr(stat, 'createdAt') else getUtcTimestamp()
|
# Use _createdAt (system field from DB, preserved via model_config extra="allow")
|
||||||
|
stat_timestamp = getattr(stat, '_createdAt', None) or getUtcTimestamp()
|
||||||
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
|
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ class TransactionResponse(BaseModel):
|
||||||
featureCode: Optional[str]
|
featureCode: Optional[str]
|
||||||
aicoreProvider: Optional[str]
|
aicoreProvider: Optional[str]
|
||||||
createdAt: Optional[datetime]
|
createdAt: Optional[datetime]
|
||||||
|
mandateId: Optional[str] = None
|
||||||
|
mandateName: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AccountSummary(BaseModel):
|
class AccountSummary(BaseModel):
|
||||||
|
|
@ -97,6 +99,53 @@ class UsageReportResponse(BaseModel):
|
||||||
costByFeature: Dict[str, float]
|
costByFeature: Dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Response Models for Mandate/User Views
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class MandateBalanceResponse(BaseModel):
|
||||||
|
"""Mandate-level balance summary."""
|
||||||
|
mandateId: str
|
||||||
|
mandateName: str
|
||||||
|
billingModel: str
|
||||||
|
totalBalance: float
|
||||||
|
userCount: int
|
||||||
|
defaultUserCredit: float
|
||||||
|
warningThresholdPercent: float
|
||||||
|
blockOnZeroBalance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserBalanceResponse(BaseModel):
|
||||||
|
"""User-level balance summary."""
|
||||||
|
accountId: str
|
||||||
|
mandateId: str
|
||||||
|
mandateName: str
|
||||||
|
userId: str
|
||||||
|
userName: str
|
||||||
|
balance: float
|
||||||
|
warningThreshold: float
|
||||||
|
isWarning: bool
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserTransactionResponse(BaseModel):
|
||||||
|
"""User-level transaction with user context."""
|
||||||
|
id: str
|
||||||
|
accountId: str
|
||||||
|
transactionType: TransactionTypeEnum
|
||||||
|
amount: float
|
||||||
|
description: str
|
||||||
|
referenceType: Optional[ReferenceTypeEnum]
|
||||||
|
workflowId: Optional[str]
|
||||||
|
featureCode: Optional[str]
|
||||||
|
aicoreProvider: Optional[str]
|
||||||
|
createdAt: Optional[datetime]
|
||||||
|
mandateId: Optional[str] = None
|
||||||
|
mandateName: Optional[str] = None
|
||||||
|
userId: Optional[str] = None
|
||||||
|
userName: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Router Setup
|
# Router Setup
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -186,7 +235,7 @@ async def getTransactions(
|
||||||
ctx: RequestContext = Depends(getRequestContext)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get transaction history for the current mandate.
|
Get transaction history across all mandates the user belongs to.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
|
|
@ -195,7 +244,8 @@ async def getTransactions(
|
||||||
featureCode="billing"
|
featureCode="billing"
|
||||||
)
|
)
|
||||||
|
|
||||||
transactions = billingService.getTransactionHistory(limit=limit)
|
# Fetch enough transactions for pagination
|
||||||
|
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
||||||
|
|
||||||
# Convert to response model
|
# Convert to response model
|
||||||
result = []
|
result = []
|
||||||
|
|
@ -210,7 +260,9 @@ async def getTransactions(
|
||||||
workflowId=t.get("workflowId"),
|
workflowId=t.get("workflowId"),
|
||||||
featureCode=t.get("featureCode"),
|
featureCode=t.get("featureCode"),
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
createdAt=t.get("_createdAt")
|
createdAt=t.get("_createdAt"),
|
||||||
|
mandateId=t.get("mandateId"),
|
||||||
|
mandateName=t.get("mandateName")
|
||||||
))
|
))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -607,3 +659,188 @@ async def getTransactionsAdmin(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {e}")
|
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mandate View Endpoints (for Admins)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/view/mandates/balances", response_model=List[MandateBalanceResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getMandateViewBalances(
|
||||||
|
request: Request,
|
||||||
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
|
_admin = Depends(requireSysAdmin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get mandate-level balances (SysAdmin only).
|
||||||
|
Shows aggregated balances per mandate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
balances = billingInterface.getMandateBalances()
|
||||||
|
|
||||||
|
return [MandateBalanceResponse(**b) for b in balances]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mandate view balances: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view/mandates/transactions", response_model=List[TransactionResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getMandateViewTransactions(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
|
_admin = Depends(requireSysAdmin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all transactions across mandates (SysAdmin only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
transactions = billingInterface.getMandateTransactions(limit=limit)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for t in transactions:
|
||||||
|
result.append(TransactionResponse(
|
||||||
|
id=t.get("id"),
|
||||||
|
accountId=t.get("accountId"),
|
||||||
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
|
amount=t.get("amount", 0.0),
|
||||||
|
description=t.get("description", ""),
|
||||||
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||||
|
workflowId=t.get("workflowId"),
|
||||||
|
featureCode=t.get("featureCode"),
|
||||||
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
|
createdAt=t.get("_createdAt"),
|
||||||
|
mandateId=t.get("mandateId"),
|
||||||
|
mandateName=t.get("mandateName")
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mandate view transactions: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User View Endpoints (RBAC-based)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/view/users/balances", response_model=List[UserBalanceResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getUserViewBalances(
|
||||||
|
request: Request,
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get user-level balances.
|
||||||
|
- SysAdmin: sees all user balances across all mandates
|
||||||
|
- MandateAdmin: sees user balances for mandates they manage
|
||||||
|
- Regular user: sees only their own balances
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
|
||||||
|
# Determine which mandates the user has access to
|
||||||
|
if ctx.user.isSysAdmin:
|
||||||
|
# SysAdmin sees all
|
||||||
|
mandateIds = None
|
||||||
|
else:
|
||||||
|
# Get mandates where user is admin or has billing access
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
appInterface = getAppInterface(ctx.user)
|
||||||
|
userMandates = appInterface.getUserMandates(ctx.user.id)
|
||||||
|
|
||||||
|
# Filter to only mandates where user has admin role
|
||||||
|
# For simplicity, we'll check if user is admin in any mandate
|
||||||
|
mandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if mandateId:
|
||||||
|
mandateIds.append(mandateId)
|
||||||
|
|
||||||
|
if not mandateIds:
|
||||||
|
return []
|
||||||
|
|
||||||
|
allBalances = billingInterface.getUserBalancesForMandates(mandateIds)
|
||||||
|
|
||||||
|
# Non-admin users only see their own balances
|
||||||
|
if not ctx.user.isSysAdmin:
|
||||||
|
allBalances = [b for b in allBalances if b.get("userId") == ctx.user.id]
|
||||||
|
|
||||||
|
return [UserBalanceResponse(**b) for b in allBalances]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user view balances: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/view/users/transactions", response_model=List[UserTransactionResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getUserViewTransactions(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get user-level transactions.
|
||||||
|
- SysAdmin: sees all user transactions across all mandates
|
||||||
|
- MandateAdmin: sees user transactions for mandates they manage
|
||||||
|
- Regular user: sees only their own transactions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
|
||||||
|
# Determine which mandates the user has access to
|
||||||
|
if ctx.user.isSysAdmin:
|
||||||
|
# SysAdmin sees all
|
||||||
|
mandateIds = None
|
||||||
|
else:
|
||||||
|
# Get mandates where user has access
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
appInterface = getAppInterface(ctx.user)
|
||||||
|
userMandates = appInterface.getUserMandates(ctx.user.id)
|
||||||
|
|
||||||
|
mandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if mandateId:
|
||||||
|
mandateIds.append(mandateId)
|
||||||
|
|
||||||
|
if not mandateIds:
|
||||||
|
return []
|
||||||
|
|
||||||
|
allTransactions = billingInterface.getUserTransactionsForMandates(mandateIds, limit=limit)
|
||||||
|
|
||||||
|
# Non-admin users only see their own transactions
|
||||||
|
if not ctx.user.isSysAdmin:
|
||||||
|
allTransactions = [t for t in allTransactions if t.get("userId") == ctx.user.id]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for t in allTransactions:
|
||||||
|
result.append(UserTransactionResponse(
|
||||||
|
id=t.get("id"),
|
||||||
|
accountId=t.get("accountId"),
|
||||||
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
|
amount=t.get("amount", 0.0),
|
||||||
|
description=t.get("description", ""),
|
||||||
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||||
|
workflowId=t.get("workflowId"),
|
||||||
|
featureCode=t.get("featureCode"),
|
||||||
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
|
createdAt=t.get("_createdAt"),
|
||||||
|
mandateId=t.get("mandateId"),
|
||||||
|
mandateName=t.get("mandateName"),
|
||||||
|
userId=t.get("userId"),
|
||||||
|
userName=t.get("userName")
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user view transactions: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,10 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
connection.externalId = user_info.get("id")
|
connection.externalId = user_info.get("id")
|
||||||
connection.externalUsername = user_info.get("email")
|
connection.externalUsername = user_info.get("email")
|
||||||
connection.externalEmail = user_info.get("email")
|
connection.externalEmail = user_info.get("email")
|
||||||
|
# Store actually granted scopes for this connection
|
||||||
|
granted_scopes_list = granted_scopes.split(" ") if granted_scopes else SCOPES
|
||||||
|
connection.grantedScopes = granted_scopes_list
|
||||||
|
logger.info(f"Storing granted scopes for connection {connection_id}: {granted_scopes_list}")
|
||||||
|
|
||||||
# Update connection record directly
|
# Update connection record directly
|
||||||
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,9 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
connection.externalId = user_info.get("id")
|
connection.externalId = user_info.get("id")
|
||||||
connection.externalUsername = user_info.get("userPrincipalName")
|
connection.externalUsername = user_info.get("userPrincipalName")
|
||||||
connection.externalEmail = user_info.get("mail")
|
connection.externalEmail = user_info.get("mail")
|
||||||
|
# Store granted scopes for this connection
|
||||||
|
connection.grantedScopes = SCOPES
|
||||||
|
logger.info(f"Storing granted scopes for connection {connection_id}: {SCOPES}")
|
||||||
|
|
||||||
# Update connection record directly
|
# Update connection record directly
|
||||||
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ class BillingService:
|
||||||
|
|
||||||
def getTransactionHistory(self, limit: int = 100) -> List[Dict[str, Any]]:
|
def getTransactionHistory(self, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get transaction history for the current mandate.
|
Get transaction history for the user across all mandates.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: Maximum number of transactions
|
limit: Maximum number of transactions
|
||||||
|
|
@ -375,7 +375,7 @@ class BillingService:
|
||||||
Returns:
|
Returns:
|
||||||
List of transactions
|
List of transactions
|
||||||
"""
|
"""
|
||||||
return self._billingInterface.getTransactionsByMandate(self.mandateId, limit=limit)
|
return self._billingInterface.getTransactionsForUser(self.currentUser.id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,14 @@ class GenerationService:
|
||||||
document_data_dict = document_data.dict()
|
document_data_dict = document_data.dict()
|
||||||
elif isinstance(document_data, dict):
|
elif isinstance(document_data, dict):
|
||||||
document_data_dict = document_data
|
document_data_dict = document_data
|
||||||
|
elif isinstance(document_data, str):
|
||||||
|
# JSON-String: parsen und als dict speichern (z.B. von outlook.composeAndDraftEmailWithContext)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
document_data_dict = json.loads(document_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Kein valides JSON - als plain text speichern
|
||||||
|
document_data_dict = {"data": document_data}
|
||||||
else:
|
else:
|
||||||
document_data_dict = {"data": str(document_data)}
|
document_data_dict = {"data": str(document_data)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,21 +96,13 @@ NAVIGATION_SECTIONS = [
|
||||||
"title": {"en": "BILLING", "de": "BILLING", "fr": "FACTURATION"},
|
"title": {"en": "BILLING", "de": "BILLING", "fr": "FACTURATION"},
|
||||||
"order": 35,
|
"order": 35,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
|
||||||
"id": "billing-dashboard",
|
|
||||||
"objectKey": "ui.billing.dashboard",
|
|
||||||
"label": {"en": "Balance", "de": "Guthaben", "fr": "Solde"},
|
|
||||||
"icon": "FaWallet",
|
|
||||||
"path": "/billing",
|
|
||||||
"order": 10,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "billing-transactions",
|
"id": "billing-transactions",
|
||||||
"objectKey": "ui.billing.transactions",
|
"objectKey": "ui.billing.transactions",
|
||||||
"label": {"en": "Transactions", "de": "Transaktionen", "fr": "Transactions"},
|
"label": {"en": "Billing", "de": "Billing", "fr": "Facturation"},
|
||||||
"icon": "FaListAlt",
|
"icon": "FaWallet",
|
||||||
"path": "/billing/transactions",
|
"path": "/billing/transactions",
|
||||||
"order": 20,
|
"order": 10,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,17 @@ logger = logging.getLogger(__name__)
|
||||||
async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
try:
|
try:
|
||||||
connectionReference = parameters.get("connectionReference")
|
connectionReference = parameters.get("connectionReference")
|
||||||
to = parameters.get("to")
|
to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients
|
||||||
context = parameters.get("context")
|
context = parameters.get("context")
|
||||||
documentList = parameters.get("documentList", [])
|
documentList = parameters.get("documentList") or []
|
||||||
cc = parameters.get("cc", [])
|
cc = parameters.get("cc") or []
|
||||||
bcc = parameters.get("bcc", [])
|
bcc = parameters.get("bcc") or []
|
||||||
emailStyle = parameters.get("emailStyle", "business")
|
emailStyle = parameters.get("emailStyle") or "business"
|
||||||
maxLength = parameters.get("maxLength", 1000)
|
maxLength = parameters.get("maxLength") or 1000
|
||||||
|
|
||||||
if not connectionReference or not to or not context:
|
# Only connectionReference and context are required - to is optional for drafts
|
||||||
return ActionResult.isFailure(error="connectionReference, to, and context are required")
|
if not connectionReference or not context:
|
||||||
|
return ActionResult.isFailure(error="connectionReference and context are required")
|
||||||
|
|
||||||
# Convert single values to lists for all recipient parameters
|
# Convert single values to lists for all recipient parameters
|
||||||
if isinstance(to, str):
|
if isinstance(to, str):
|
||||||
|
|
@ -82,12 +83,15 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A
|
||||||
# Escape only the user-controlled context to prevent prompt injection
|
# Escape only the user-controlled context to prevent prompt injection
|
||||||
escaped_context = context.replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r')
|
escaped_context = context.replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r')
|
||||||
|
|
||||||
|
# Build recipients text for prompt
|
||||||
|
recipients_text = f"Recipients: {to}" if to else "Recipients: (not specified - this is a draft)"
|
||||||
|
|
||||||
ai_prompt = f"""Compose an email based on this context:
|
ai_prompt = f"""Compose an email based on this context:
|
||||||
-------
|
-------
|
||||||
{escaped_context}
|
{escaped_context}
|
||||||
-------
|
-------
|
||||||
|
|
||||||
Recipients: {to}
|
{recipients_text}
|
||||||
Style: {emailStyle}
|
Style: {emailStyle}
|
||||||
Max length: {maxLength} characters
|
Max length: {maxLength} characters
|
||||||
{doc_list_text}
|
{doc_list_text}
|
||||||
|
|
|
||||||
|
|
@ -90,15 +90,20 @@ async def sendDraftEmail(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
else:
|
else:
|
||||||
jsonContent = str(fileData)
|
jsonContent = str(fileData)
|
||||||
|
|
||||||
# Parse JSON - handle both direct JSON and JSON wrapped in documentData
|
# Parse JSON - handle ActionDocument format with validationMetadata wrapper
|
||||||
try:
|
try:
|
||||||
draftEmailData = json.loads(jsonContent)
|
draftEmailData = json.loads(jsonContent)
|
||||||
|
|
||||||
# If the JSON contains a 'documentData' field, extract it
|
# ActionDocument format: { "validationMetadata": {...}, "documentData": {...} }
|
||||||
|
# Extract documentData which contains the actual draft email data
|
||||||
if isinstance(draftEmailData, dict) and 'documentData' in draftEmailData:
|
if isinstance(draftEmailData, dict) and 'documentData' in draftEmailData:
|
||||||
documentDataStr = draftEmailData['documentData']
|
documentDataContent = draftEmailData['documentData']
|
||||||
if isinstance(documentDataStr, str):
|
# documentData should be a dict (parsed from JSON by processSingleDocument)
|
||||||
draftEmailData = json.loads(documentDataStr)
|
if isinstance(documentDataContent, dict):
|
||||||
|
draftEmailData = documentDataContent
|
||||||
|
elif isinstance(documentDataContent, str):
|
||||||
|
# Legacy/fallback: parse if still a string
|
||||||
|
draftEmailData = json.loads(documentDataContent)
|
||||||
|
|
||||||
# Validate draft email structure
|
# Validate draft email structure
|
||||||
if not isinstance(draftEmailData, dict):
|
if not isinstance(draftEmailData, dict):
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,14 @@ class ConnectionHelper:
|
||||||
elif response.status_code == 403:
|
elif response.status_code == 403:
|
||||||
logger.error("Permission denied - connection lacks necessary mail permissions")
|
logger.error("Permission denied - connection lacks necessary mail permissions")
|
||||||
logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared")
|
logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared")
|
||||||
|
logger.error("Solution: User must reconnect and grant mail permissions")
|
||||||
|
return False
|
||||||
|
elif response.status_code == 404:
|
||||||
|
# 404 on /me/mailFolders typically means the token lacks mail scopes
|
||||||
|
# This happens when the connection was created without mail permissions
|
||||||
|
logger.error("Mail API not accessible (404) - token likely lacks mail scopes")
|
||||||
|
logger.error("This usually means the connection was created without Mail.ReadWrite permission")
|
||||||
|
logger.error("Solution: User must delete the connection and reconnect, granting mail permissions")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Permission check returned status {response.status_code}")
|
logger.warning(f"Permission check returned status {response.status_code}")
|
||||||
|
|
|
||||||
|
|
@ -150,8 +150,8 @@ class MethodOutlook(MethodBase):
|
||||||
name="to",
|
name="to",
|
||||||
type="List[str]",
|
type="List[str]",
|
||||||
frontendType=FrontendType.MULTISELECT,
|
frontendType=FrontendType.MULTISELECT,
|
||||||
required=True,
|
required=False,
|
||||||
description="Recipient email addresses"
|
description="Recipient email addresses (optional for drafts)"
|
||||||
),
|
),
|
||||||
"context": WorkflowActionParameter(
|
"context": WorkflowActionParameter(
|
||||||
name="context",
|
name="context",
|
||||||
|
|
|
||||||
|
|
@ -204,29 +204,29 @@ class DynamicMode(BaseMode):
|
||||||
if quality_score is None:
|
if quality_score is None:
|
||||||
quality_score = 0.0
|
quality_score = 0.0
|
||||||
logger.info(f"Content validation: {validationResult.get('overallSuccess', False)} (quality: {quality_score:.2f})")
|
logger.info(f"Content validation: {validationResult.get('overallSuccess', False)} (quality: {quality_score:.2f})")
|
||||||
|
|
||||||
|
# Record validation result for adaptive learning
|
||||||
|
actionValue = selection.get('action', 'unknown')
|
||||||
|
actionContext = {
|
||||||
|
'actionName': actionValue,
|
||||||
|
'workflowId': context.workflowId
|
||||||
|
}
|
||||||
|
|
||||||
|
self.adaptiveLearningEngine.recordValidationResult(
|
||||||
|
validationResult,
|
||||||
|
actionContext,
|
||||||
|
context.workflowId,
|
||||||
|
step
|
||||||
|
)
|
||||||
|
|
||||||
|
# Learn from feedback - use taskIntent (task-level), not workflowIntent
|
||||||
|
feedback = self._collectFeedback(result, validationResult, self.taskIntent)
|
||||||
|
self.learningEngine.learnFromFeedback(feedback, context, self.taskIntent)
|
||||||
|
|
||||||
|
# Update progress - use taskIntent (task-level), not workflowIntent
|
||||||
|
self.progressTracker.updateOperation(result, validationResult, self.taskIntent)
|
||||||
else:
|
else:
|
||||||
logger.info("Content validation skipped: no documents to validate")
|
logger.info("Content validation skipped: no documents to validate")
|
||||||
|
|
||||||
# NEW: Record validation result for adaptive learning
|
|
||||||
actionValue = selection.get('action', 'unknown')
|
|
||||||
actionContext = {
|
|
||||||
'actionName': actionValue,
|
|
||||||
'workflowId': context.workflowId
|
|
||||||
}
|
|
||||||
|
|
||||||
self.adaptiveLearningEngine.recordValidationResult(
|
|
||||||
validationResult,
|
|
||||||
actionContext,
|
|
||||||
context.workflowId,
|
|
||||||
step
|
|
||||||
)
|
|
||||||
|
|
||||||
# NEW: Learn from feedback - use taskIntent (task-level), not workflowIntent
|
|
||||||
feedback = self._collectFeedback(result, validationResult, self.taskIntent)
|
|
||||||
self.learningEngine.learnFromFeedback(feedback, context, self.taskIntent)
|
|
||||||
|
|
||||||
# NEW: Update progress - use taskIntent (task-level), not workflowIntent
|
|
||||||
self.progressTracker.updateOperation(result, validationResult, self.taskIntent)
|
|
||||||
|
|
||||||
decision = await self._refineDecide(context, observation)
|
decision = await self._refineDecide(context, observation)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -430,11 +430,33 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
workflow = self.services.workflow
|
workflow = self.services.workflow
|
||||||
checkWorkflowStopped(self.services)
|
checkWorkflowStopped(self.services)
|
||||||
|
|
||||||
|
# Send "first" message to mark round start (consistent with full workflow)
|
||||||
|
normalizedRequest = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
|
||||||
|
roundNum = workflow.currentRound
|
||||||
|
contextLabel = f"round{roundNum}_usercontext"
|
||||||
|
|
||||||
|
firstMessageData = {
|
||||||
|
"workflowId": workflow.id,
|
||||||
|
"role": "user",
|
||||||
|
"message": normalizedRequest,
|
||||||
|
"status": "first",
|
||||||
|
"sequenceNr": len(workflow.messages) + 1,
|
||||||
|
"publishedAt": self.services.utils.timestampGetUtc(),
|
||||||
|
"documentsLabel": contextLabel,
|
||||||
|
"documents": [],
|
||||||
|
"roundNumber": roundNum,
|
||||||
|
"taskNumber": 0,
|
||||||
|
"actionNumber": 0,
|
||||||
|
"taskProgress": "pending",
|
||||||
|
"actionProgress": "pending"
|
||||||
|
}
|
||||||
|
self.services.chat.storeMessageWithDocuments(workflow, firstMessageData, [])
|
||||||
|
|
||||||
# Get user language if available
|
# Get user language if available
|
||||||
userLanguage = getattr(self.services, 'currentUserLanguage', None)
|
userLanguage = getattr(self.services, 'currentUserLanguage', None)
|
||||||
|
|
||||||
# Execute fast path - use normalizedRequest if available, otherwise use raw prompt
|
# Execute fast path - use normalizedRequest if available, otherwise use raw prompt
|
||||||
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
|
normalizedPrompt = normalizedRequest
|
||||||
result = await self.workflowProcessor.fastPathExecute(
|
result = await self.workflowProcessor.fastPathExecute(
|
||||||
prompt=normalizedPrompt,
|
prompt=normalizedPrompt,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
|
@ -491,14 +513,6 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
}
|
}
|
||||||
chatDocuments.append(chatDoc)
|
chatDocuments.append(chatDoc)
|
||||||
|
|
||||||
# Mark workflow as completed BEFORE storing message (so UI polling stops)
|
|
||||||
workflow.status = "completed"
|
|
||||||
workflow.lastActivity = self.services.utils.timestampGetUtc()
|
|
||||||
self.services.chat.updateWorkflow(workflow.id, {
|
|
||||||
"status": "completed",
|
|
||||||
"lastActivity": workflow.lastActivity
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create ChatMessage with fast path response (in user's language)
|
# Create ChatMessage with fast path response (in user's language)
|
||||||
messageData = {
|
messageData = {
|
||||||
"workflowId": workflow.id,
|
"workflowId": workflow.id,
|
||||||
|
|
@ -518,9 +532,18 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
"actionProgress": "success"
|
"actionProgress": "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Store message with documents
|
# Store message with documents BEFORE marking workflow as completed
|
||||||
|
# This ensures UI polling sees the "last" message before status changes
|
||||||
self.services.chat.storeMessageWithDocuments(workflow, messageData, chatDocuments)
|
self.services.chat.storeMessageWithDocuments(workflow, messageData, chatDocuments)
|
||||||
|
|
||||||
|
# Mark workflow as completed AFTER storing message
|
||||||
|
workflow.status = "completed"
|
||||||
|
workflow.lastActivity = self.services.utils.timestampGetUtc()
|
||||||
|
self.services.chat.updateWorkflow(workflow.id, {
|
||||||
|
"status": "completed",
|
||||||
|
"lastActivity": workflow.lastActivity
|
||||||
|
})
|
||||||
|
|
||||||
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
|
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue