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
|
||||
registerAuditLogCleanupScheduler()
|
||||
|
||||
# Ensure billing settings and accounts exist
|
||||
# Ensure billing settings and accounts exist for all mandates
|
||||
try:
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
||||
|
||||
billingInterface = getBillingRootInterface()
|
||||
|
||||
# Ensure root mandate has billing settings
|
||||
rootMandate = rootInterface.getRootMandate()
|
||||
if rootMandate:
|
||||
rootMandateId = rootMandate.get("id") if isinstance(rootMandate, dict) else getattr(rootMandate, "id", None)
|
||||
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")
|
||||
# Step 1: Ensure all mandates have billing settings (creates defaults if missing)
|
||||
settingsCreated = billingInterface.ensureAllMandateSettingsExist()
|
||||
if settingsCreated > 0:
|
||||
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
|
||||
|
||||
# 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()
|
||||
if accountsCreated > 0:
|
||||
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import uuid
|
|||
|
||||
class ChatStat(BaseModel):
|
||||
"""Statistics for chat operations. User-owned, no mandate context."""
|
||||
model_config = {"populate_by_name": True, "extra": "allow"} # Allow DB system fields
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
|
|
@ -41,7 +43,7 @@ registerModelLabels(
|
|||
"errorCount": {"en": "Error Count", "fr": "Nombre d'erreurs"},
|
||||
"process": {"en": "Process", "fr": "Processus"},
|
||||
"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"}},
|
||||
]})
|
||||
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
|
||||
|
|
@ -146,6 +147,7 @@ registerModelLabels(
|
|||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
||||
"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"},
|
||||
"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)
|
||||
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()
|
||||
message_timestamp = parseTimestamp(chat_message.publishedAt, default=getUtcTimestamp())
|
||||
# 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
|
||||
if workflow.workflowMode == WorkflowModeEnum.WORKFLOW_CHATBOT:
|
||||
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()
|
||||
log_timestamp = parseTimestamp(createdLog.get("timestamp"), default=getUtcTimestamp())
|
||||
# Emit log event in exact chatData format: {type, createdAt, item}
|
||||
|
|
@ -1563,8 +1563,8 @@ class ChatObjects:
|
|||
if not stats:
|
||||
return []
|
||||
|
||||
# Return all stats records sorted by creation time
|
||||
stats.sort(key=lambda x: x.get("created_at", ""))
|
||||
# Return all stats records sorted by _createdAt (system field from DB)
|
||||
stats.sort(key=lambda x: x.get("_createdAt", 0))
|
||||
# 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]
|
||||
|
||||
|
|
@ -1680,11 +1680,12 @@ class ChatObjects:
|
|||
"item": chatLog
|
||||
})
|
||||
|
||||
# Get stats list
|
||||
# Get stats - ChatStat model now supports _createdAt via extra="allow"
|
||||
stats = self.getStats(workflowId)
|
||||
for stat in stats:
|
||||
# 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:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ _gatewayInterfaces = {}
|
|||
# Root interface instance
|
||||
_rootAppObjects = None
|
||||
|
||||
# Bootstrap completion flag - ensures bootstrap runs only ONCE per application lifecycle
|
||||
_bootstrapCompleted = False
|
||||
|
||||
# Password-Hashing
|
||||
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
|
@ -200,8 +203,28 @@ class AppObjects:
|
|||
return simpleFields, objectFields
|
||||
|
||||
def _initRecords(self):
|
||||
"""Initialize standard records if they don't exist."""
|
||||
"""Initialize standard records if they don't exist.
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import uuid
|
|||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
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 (
|
||||
BillingAccount,
|
||||
BillingTransaction,
|
||||
|
|
@ -360,6 +361,60 @@ class BillingObjects:
|
|||
|
||||
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:
|
||||
"""
|
||||
Efficiently ensure all users across all mandates have billing accounts.
|
||||
|
|
@ -368,10 +423,7 @@ class BillingObjects:
|
|||
Returns:
|
||||
Number of accounts created
|
||||
"""
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
||||
|
||||
try:
|
||||
appInterface = getAppRootInterface()
|
||||
accountsCreated = 0
|
||||
|
||||
# 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")
|
||||
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(
|
||||
BillingAccount,
|
||||
recordFilter={"accountType": AccountTypeEnum.USER.value}
|
||||
|
|
@ -396,9 +448,16 @@ class BillingObjects:
|
|||
key = (acc.get("mandateId"), acc.get("userId"))
|
||||
existingAccountKeys.add(key)
|
||||
|
||||
# Step 3: Get all user-mandate combinations in one query
|
||||
allUserMandates = appInterface.db.getRecordset(
|
||||
appInterface.db.getModel("UserMandate"),
|
||||
# Step 3: Get all user-mandate combinations 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')
|
||||
)
|
||||
allUserMandates = appDb.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"enabled": True}
|
||||
)
|
||||
|
||||
|
|
@ -855,3 +914,338 @@ class BillingObjects:
|
|||
logger.error(f"Error getting balances for user: {e}")
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
# Get stats list
|
||||
# Get stats - ChatStat model supports _createdAt via model_config extra="allow"
|
||||
stats = self.getStats(workflowId)
|
||||
for stat in stats:
|
||||
# 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:
|
||||
continue
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ class TransactionResponse(BaseModel):
|
|||
featureCode: Optional[str]
|
||||
aicoreProvider: Optional[str]
|
||||
createdAt: Optional[datetime]
|
||||
mandateId: Optional[str] = None
|
||||
mandateName: Optional[str] = None
|
||||
|
||||
|
||||
class AccountSummary(BaseModel):
|
||||
|
|
@ -97,6 +99,53 @@ class UsageReportResponse(BaseModel):
|
|||
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
|
||||
# =============================================================================
|
||||
|
|
@ -186,7 +235,7 @@ async def getTransactions(
|
|||
ctx: RequestContext = Depends(getRequestContext)
|
||||
):
|
||||
"""
|
||||
Get transaction history for the current mandate.
|
||||
Get transaction history across all mandates the user belongs to.
|
||||
"""
|
||||
try:
|
||||
billingService = getBillingService(
|
||||
|
|
@ -195,7 +244,8 @@ async def getTransactions(
|
|||
featureCode="billing"
|
||||
)
|
||||
|
||||
transactions = billingService.getTransactionHistory(limit=limit)
|
||||
# Fetch enough transactions for pagination
|
||||
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
||||
|
||||
# Convert to response model
|
||||
result = []
|
||||
|
|
@ -210,7 +260,9 @@ async def getTransactions(
|
|||
workflowId=t.get("workflowId"),
|
||||
featureCode=t.get("featureCode"),
|
||||
aicoreProvider=t.get("aicoreProvider"),
|
||||
createdAt=t.get("_createdAt")
|
||||
createdAt=t.get("_createdAt"),
|
||||
mandateId=t.get("mandateId"),
|
||||
mandateName=t.get("mandateName")
|
||||
))
|
||||
|
||||
return result
|
||||
|
|
@ -607,3 +659,188 @@ async def getTransactionsAdmin(
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {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.externalUsername = 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
|
||||
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.externalUsername = user_info.get("userPrincipalName")
|
||||
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
|
||||
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]]:
|
||||
"""
|
||||
Get transaction history for the current mandate.
|
||||
Get transaction history for the user across all mandates.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of transactions
|
||||
|
|
@ -375,7 +375,7 @@ class BillingService:
|
|||
Returns:
|
||||
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()
|
||||
elif isinstance(document_data, dict):
|
||||
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:
|
||||
document_data_dict = {"data": str(document_data)}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,21 +96,13 @@ NAVIGATION_SECTIONS = [
|
|||
"title": {"en": "BILLING", "de": "BILLING", "fr": "FACTURATION"},
|
||||
"order": 35,
|
||||
"items": [
|
||||
{
|
||||
"id": "billing-dashboard",
|
||||
"objectKey": "ui.billing.dashboard",
|
||||
"label": {"en": "Balance", "de": "Guthaben", "fr": "Solde"},
|
||||
"icon": "FaWallet",
|
||||
"path": "/billing",
|
||||
"order": 10,
|
||||
},
|
||||
{
|
||||
"id": "billing-transactions",
|
||||
"objectKey": "ui.billing.transactions",
|
||||
"label": {"en": "Transactions", "de": "Transaktionen", "fr": "Transactions"},
|
||||
"icon": "FaListAlt",
|
||||
"label": {"en": "Billing", "de": "Billing", "fr": "Facturation"},
|
||||
"icon": "FaWallet",
|
||||
"path": "/billing/transactions",
|
||||
"order": 20,
|
||||
"order": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,16 +13,17 @@ logger = logging.getLogger(__name__)
|
|||
async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||
try:
|
||||
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")
|
||||
documentList = parameters.get("documentList", [])
|
||||
cc = parameters.get("cc", [])
|
||||
bcc = parameters.get("bcc", [])
|
||||
emailStyle = parameters.get("emailStyle", "business")
|
||||
maxLength = parameters.get("maxLength", 1000)
|
||||
documentList = parameters.get("documentList") or []
|
||||
cc = parameters.get("cc") or []
|
||||
bcc = parameters.get("bcc") or []
|
||||
emailStyle = parameters.get("emailStyle") or "business"
|
||||
maxLength = parameters.get("maxLength") or 1000
|
||||
|
||||
if not connectionReference or not to or not context:
|
||||
return ActionResult.isFailure(error="connectionReference, to, and context are required")
|
||||
# Only connectionReference and context are required - to is optional for drafts
|
||||
if not connectionReference or not context:
|
||||
return ActionResult.isFailure(error="connectionReference and context are required")
|
||||
|
||||
# Convert single values to lists for all recipient parameters
|
||||
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
|
||||
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:
|
||||
-------
|
||||
{escaped_context}
|
||||
-------
|
||||
|
||||
Recipients: {to}
|
||||
{recipients_text}
|
||||
Style: {emailStyle}
|
||||
Max length: {maxLength} characters
|
||||
{doc_list_text}
|
||||
|
|
|
|||
|
|
@ -90,15 +90,20 @@ async def sendDraftEmail(self, parameters: Dict[str, Any]) -> ActionResult:
|
|||
else:
|
||||
jsonContent = str(fileData)
|
||||
|
||||
# Parse JSON - handle both direct JSON and JSON wrapped in documentData
|
||||
# Parse JSON - handle ActionDocument format with validationMetadata wrapper
|
||||
try:
|
||||
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:
|
||||
documentDataStr = draftEmailData['documentData']
|
||||
if isinstance(documentDataStr, str):
|
||||
draftEmailData = json.loads(documentDataStr)
|
||||
documentDataContent = draftEmailData['documentData']
|
||||
# documentData should be a dict (parsed from JSON by processSingleDocument)
|
||||
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
|
||||
if not isinstance(draftEmailData, dict):
|
||||
|
|
|
|||
|
|
@ -84,6 +84,14 @@ class ConnectionHelper:
|
|||
elif response.status_code == 403:
|
||||
logger.error("Permission denied - connection lacks necessary mail permissions")
|
||||
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
|
||||
else:
|
||||
logger.warning(f"Permission check returned status {response.status_code}")
|
||||
|
|
|
|||
|
|
@ -150,8 +150,8 @@ class MethodOutlook(MethodBase):
|
|||
name="to",
|
||||
type="List[str]",
|
||||
frontendType=FrontendType.MULTISELECT,
|
||||
required=True,
|
||||
description="Recipient email addresses"
|
||||
required=False,
|
||||
description="Recipient email addresses (optional for drafts)"
|
||||
),
|
||||
"context": WorkflowActionParameter(
|
||||
name="context",
|
||||
|
|
|
|||
|
|
@ -204,10 +204,8 @@ class DynamicMode(BaseMode):
|
|||
if quality_score is None:
|
||||
quality_score = 0.0
|
||||
logger.info(f"Content validation: {validationResult.get('overallSuccess', False)} (quality: {quality_score:.2f})")
|
||||
else:
|
||||
logger.info("Content validation skipped: no documents to validate")
|
||||
|
||||
# NEW: Record validation result for adaptive learning
|
||||
# Record validation result for adaptive learning
|
||||
actionValue = selection.get('action', 'unknown')
|
||||
actionContext = {
|
||||
'actionName': actionValue,
|
||||
|
|
@ -221,12 +219,14 @@ class DynamicMode(BaseMode):
|
|||
step
|
||||
)
|
||||
|
||||
# NEW: Learn from feedback - use taskIntent (task-level), not workflowIntent
|
||||
# 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
|
||||
# Update progress - use taskIntent (task-level), not workflowIntent
|
||||
self.progressTracker.updateOperation(result, validationResult, self.taskIntent)
|
||||
else:
|
||||
logger.info("Content validation skipped: no documents to validate")
|
||||
|
||||
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
|
||||
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
|
||||
userLanguage = getattr(self.services, 'currentUserLanguage', None)
|
||||
|
||||
# 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(
|
||||
prompt=normalizedPrompt,
|
||||
documents=documents,
|
||||
|
|
@ -491,14 +513,6 @@ The following is the user's original input message. Analyze intent, normalize th
|
|||
}
|
||||
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)
|
||||
messageData = {
|
||||
"workflowId": workflow.id,
|
||||
|
|
@ -518,9 +532,18 @@ The following is the user's original input message. Analyze intent, normalize th
|
|||
"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)
|
||||
|
||||
# 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")
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
Loading…
Reference in a new issue