From 40f82a3848876b655edfbfaea1a8b5cc2e18f84d Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 18 May 2025 22:25:26 +0200 Subject: [PATCH] db items serialized, uuid overall, auth enhanced --- app.py | 9 +- env_dev.env | 10 +- env_prod.env | 10 +- modules/connectors/connectorDbJson.py | 494 ++++++++++++++------- modules/interfaces/gatewayAccess.py | 34 +- modules/interfaces/gatewayInterface.py | 216 +++++---- modules/interfaces/gatewayModel.py | 8 +- modules/interfaces/lucydomAccess.py | 36 +- modules/interfaces/lucydomInterface.py | 225 ++++++---- modules/interfaces/lucydomModel BACKUP.py | 265 +++++++++++ modules/interfaces/lucydomModel.py | 245 ++++------ modules/routes/routeAttributes.py | 50 ++- modules/routes/routeFiles.py | 28 +- modules/routes/routeGeneral.py | 154 ++++++- modules/routes/routeMandates.py | 70 ++- modules/routes/routeMsft.py | 50 +-- modules/routes/routePrompts.py | 24 +- modules/routes/routeUsers.py | 71 +-- modules/routes/routeWorkflows.py | 171 +++++-- modules/security/auth.py | 79 ++-- modules/shared/defAttributes.py | 4 +- modules/workflow/agentBase.py | 13 +- modules/workflow/agentRegistry.py | 34 +- modules/workflow/workflowAgentsRegistry.py | 10 - modules/workflow/workflowManager.py | 143 +++--- notes/changelog.txt | 7 + 26 files changed, 1563 insertions(+), 897 deletions(-) create mode 100644 modules/interfaces/lucydomModel BACKUP.py delete mode 100644 modules/workflow/workflowAgentsRegistry.py diff --git a/app.py b/app.py index dba65587..89282ca1 100644 --- a/app.py +++ b/app.py @@ -57,17 +57,14 @@ initLogging() logger = logging.getLogger(__name__) instanceLabel = APP_CONFIG.get("APP_ENV_LABEL") -# Import models - import generically for INITIALIZATION -from modules.interfaces.gatewayInterface import getGatewayInterface -gateway = getGatewayInterface() - - # Define lifespan context manager for application startup/shutdown events @asynccontextmanager async def lifespan(app: FastAPI): - # Startup logic (if any) + # Startup logic logger.info("Application is starting up") + yield + # Shutdown logic logger.info("Application has been shut down") diff --git a/env_dev.env b/env_dev.env index ad9fcbaa..188ce330 100644 --- a/env_dev.env +++ b/env_dev.env @@ -5,11 +5,11 @@ APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick APP_API_URL = http://localhost:8000 -# Database Configuration System -DB_SYSTEM_HOST=D:/Temp/_powerondb -DB_SYSTEM_DATABASE=system -DB_SYSTEM_USER=dev_user -DB_SYSTEM_PASSWORD_SECRET=dev_password +# Database Configuration Gateway +DB_GATEWAY_HOST=D:/Temp/_powerondb +DB_GATEWAY_DATABASE=gateway +DB_GATEWAY_USER=dev_user +DB_GATEWAY_PASSWORD_SECRET=dev_password # Database Configuration LucyDOM DB_LUCYDOM_HOST=D:/Temp/_powerondb diff --git a/env_prod.env b/env_prod.env index 42a47166..f18db4af 100644 --- a/env_prod.env +++ b/env_prod.env @@ -5,11 +5,11 @@ APP_ENV_TYPE = prod APP_ENV_LABEL = Production Instance APP_API_URL = https://gateway.poweron-center.net -# Database Configuration System -DB_SYSTEM_HOST=/home/_powerondb -DB_SYSTEM_DATABASE=system -DB_SYSTEM_USER=dev_user -DB_SYSTEM_PASSWORD_SECRET=prod_password +# Database Configuration Gateway +DB_GATEWAY_HOST=/home/_powerondb +DB_GATEWAY_DATABASE=gateway +DB_GATEWAY_USER=dev_user +DB_GATEWAY_PASSWORD_SECRET=prod_password # Database Configuration LucyDOM DB_LUCYDOM_HOST=/home/_powerondb diff --git a/modules/connectors/connectorDbJson.py b/modules/connectors/connectorDbJson.py index 024a4984..fa23fb24 100644 --- a/modules/connectors/connectorDbJson.py +++ b/modules/connectors/connectorDbJson.py @@ -2,6 +2,8 @@ import json import os from typing import List, Dict, Any, Optional, Union import logging +from datetime import datetime +import uuid logger = logging.getLogger(__name__) @@ -9,19 +11,28 @@ class DatabaseConnector: """ A connector for JSON-based data storage. Provides generic database operations without user/mandate filtering. + Stores tables as folders and records as individual files. + Implements lazy loading for better performance. """ def __init__(self, dbHost: str, dbDatabase: str, dbUser: str = None, dbPassword: str = None, - mandateId: int = None, userId: int = None, skipInitialIdLookup: bool = False): + _mandateId: str = None, _userId: str = None, skipInitialIdLookup: bool = False): # Store the input parameters self.dbHost = dbHost self.dbDatabase = dbDatabase self.dbUser = dbUser self.dbPassword = dbPassword - self.skipInitialIdLookup = skipInitialIdLookup # Check if context parameters are set - if mandateId is None or userId is None: - raise ValueError("mandateId and userId must be set") + if _mandateId is None and _userId is None: + # Allow initialization with empty strings + self._mandateId = '' + self._userId = '' + else: + # Ensure both parameters are provided + if _mandateId is None or _userId is None: + raise ValueError("_mandateId and _userId must both be provided or both be None") + self._mandateId = _mandateId + self._userId = _userId # Ensure the database directory exists self.dbFolder = os.path.join(self.dbHost, self.dbDatabase) @@ -29,34 +40,33 @@ class DatabaseConnector: # Cache for loaded data self._tablesCache = {} + self._tableMetadataCache = {} # Cache for table metadata (record IDs, etc.) # Initialize system table self._systemTableName = "_system" self._initializeSystemTable() - # Temporarily store mandateId and userId - self._mandateId = mandateId - self._userId = userId - - # If mandateId or userId are 0 and we're not skipping ID lookup, try to use the initial IDs + # If IDs are empty and we're not skipping lookup, try to use initial IDs if not skipInitialIdLookup: - if mandateId == 0: - initialMandateId = self.getInitialId("mandates") - if initialMandateId is not None: - self._mandateId = initialMandateId - logger.info(f"Using initial mandateId: {initialMandateId} instead of 0") + self._resolveInitialIds() - if userId == 0: - initialUserId = self.getInitialId("users") - if initialUserId is not None: - self._userId = initialUserId - logger.info(f"Using initial userId: {initialUserId} instead of 0") + logger.debug(f"Context: _mandateId={self._mandateId}, _userId={self._userId}") + + def _resolveInitialIds(self): + """ + Resolve initial IDs for mandate and user if they're empty. + """ + if not self._mandateId: + initialMandateId = self.getInitialId("mandates") + if initialMandateId is not None: + self._mandateId = initialMandateId + logger.info(f"Using initial _mandateId: {initialMandateId}") - # Set the effective IDs as properties - self.mandateId = self._mandateId - self.userId = self._userId - - logger.debug(f"Context: mandateId={self.mandateId}, userId={self.userId}") + if not self._userId: + initialUserId = self.getInitialId("users") + if initialUserId is not None: + self._userId = initialUserId + logger.info(f"Using initial _userId: {initialUserId}") def _initializeSystemTable(self): """Initializes the system table if it doesn't exist yet.""" @@ -70,7 +80,7 @@ class DatabaseConnector: self._loadSystemTable() logger.debug(f"Existing system table loaded from {systemTablePath}") - def _loadSystemTable(self) -> Dict[str, int]: + def _loadSystemTable(self) -> Dict[str, str]: """Loads the system table with the initial IDs.""" # Check if system table is in cache if f"_{self._systemTableName}" in self._tablesCache: @@ -92,7 +102,7 @@ class DatabaseConnector: self._tablesCache[f"_{self._systemTableName}"] = {} return {} - def _saveSystemTable(self, data: Dict[str, int]) -> bool: + def _saveSystemTable(self, data: Dict[str, str]) -> bool: """Saves the system table with the initial IDs.""" systemTablePath = self._getTablePath(self._systemTableName) try: @@ -106,76 +116,111 @@ class DatabaseConnector: return False def _getTablePath(self, table: str) -> str: - """Returns the full path to a table file""" - return os.path.join(self.dbFolder, f"{table}.json") - - def _loadTable(self, table: str) -> List[Dict[str, Any]]: - """Loads a table from the corresponding JSON file""" - path = self._getTablePath(table) + """Returns the full path to a table folder""" + return os.path.join(self.dbFolder, table) + + def _getRecordPath(self, table: str, recordId: Union[str, int]) -> str: + """Returns the full path to a record file""" + return os.path.join(self._getTablePath(table), f"{recordId}.json") + + def _ensureTableDirectory(self, table: str) -> bool: + """Ensures the table directory exists.""" + if table == self._systemTableName: + return True + + tablePath = self._getTablePath(table) + try: + os.makedirs(tablePath, exist_ok=True) + return True + except Exception as e: + logger.error(f"Error creating table directory {tablePath}: {e}") + return False + + def _loadTableMetadata(self, table: str) -> Dict[str, Any]: + """Loads table metadata (list of record IDs) without loading actual records.""" + if table in self._tableMetadataCache: + return self._tableMetadataCache[table] + + # Ensure table directory exists + if not self._ensureTableDirectory(table): + return {"recordIds": []} + + tablePath = self._getTablePath(table) + metadata = {"recordIds": []} + try: + if os.path.exists(tablePath): + for filename in os.listdir(tablePath): + if filename.endswith('.json'): + recordId = filename[:-5] # Remove .json extension + metadata["recordIds"].append(recordId) + + metadata["recordIds"].sort() + self._tableMetadataCache[table] = metadata + except Exception as e: + logger.error(f"Error loading table metadata for {table}: {e}") + + return metadata + + def _loadRecord(self, table: str, recordId: Union[str, int]) -> Optional[Dict[str, Any]]: + """Loads a single record from the table.""" + recordPath = self._getRecordPath(table, recordId) + try: + if os.path.exists(recordPath): + with open(recordPath, 'r', encoding='utf-8') as f: + record = json.load(f) + # Ensure ID is a string + if "id" in record: + record["id"] = str(record["id"]) + return record + except Exception as e: + logger.error(f"Error loading record {recordId} from table {table}: {e}") + return None + + def _loadTable(self, table: str) -> List[Dict[str, Any]]: + """Loads all records from a table folder.""" # If the table is the system table, load it directly if table == self._systemTableName: - return [] # The system table is not treated like normal tables + return self._loadSystemTable() # If the table is already in the cache, use the cache if table in self._tablesCache: return self._tablesCache[table] - # Otherwise load the file - try: - if os.path.exists(path): - with open(path, 'r', encoding='utf-8') as f: - data = json.load(f) - self._tablesCache[table] = data - - # If data was loaded and no initial ID is registered yet, - # register the ID of the first record (if available) - if data and not self.hasInitialId(table): - if "id" in data[0]: - self._registerInitialId(table, data[0]["id"]) - logger.info(f"Initial ID {data[0]['id']} for table {table} retroactively registered") - - return data - else: - # If the file doesn't exist, create an empty table - logger.info(f"New table {table}") - self._tablesCache[table] = [] - self._saveTable(table, []) - return [] - except Exception as e: - logger.error(f"Error loading table {table}: {e}") - return [] - + # Load metadata first + metadata = self._loadTableMetadata(table) + records = [] + + # Load each record + for recordId in metadata["recordIds"]: + record = self._loadRecord(table, recordId) + if record: + records.append(record) + + self._tablesCache[table] = records + return records + def _saveTable(self, table: str, data: List[Dict[str, Any]]) -> bool: - """Saves a table to the corresponding JSON file""" + """Saves all records to a table folder""" # The system table is handled specially if table == self._systemTableName: - return False + return self._saveSystemTable(data) - path = self._getTablePath(table) + tablePath = self._getTablePath(table) try: - # Check if directory exists and is writable - dir_path = os.path.dirname(path) - if not os.path.exists(dir_path): - logger.error(f"Directory does not exist: {dir_path}") - return False - if not os.access(dir_path, os.W_OK): - logger.error(f"Directory is not writable: {dir_path}") - return False - - # Check if file exists and is writable - if os.path.exists(path) and not os.access(path, os.W_OK): - logger.error(f"File exists but is not writable: {path}") - return False - - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) + # Ensure table directory exists + os.makedirs(tablePath, exist_ok=True) + + # Save each record as a separate file + for record in data: + if "id" not in record: + logger.error(f"Record missing ID in table {table}") + continue + + recordPath = self._getRecordPath(table, record["id"]) + with open(recordPath, 'w', encoding='utf-8') as f: + json.dump(record, f, indent=2, ensure_ascii=False) - # Verify the file was written correctly - if not os.path.exists(path): - logger.error(f"File was not created after write: {path}") - return False - # Update the cache self._tablesCache[table] = data logger.debug(f"Successfully saved table {table}") @@ -185,7 +230,7 @@ class DatabaseConnector: logger.error(f"Error type: {type(e).__name__}") logger.error(f"Error details: {e.__dict__ if hasattr(e, '__dict__') else 'No details available'}") return False - + def _applyRecordFilter(self, records: List[Dict[str, Any]], recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]: """Applies a record filter to the records""" if not recordFilter: @@ -202,19 +247,12 @@ class DatabaseConnector: match = False break - # Handle type conversion for integer comparisons both ways - if isinstance(value, int) and isinstance(record[field], str) and record[field].isdigit(): - # Filter value is int, record value is string - if value != int(record[field]): - match = False - break - elif isinstance(value, str) and value.isdigit() and isinstance(record[field], int): - # Filter value is string, record value is int - if record[field] != int(value): - match = False - break - # Otherwise direct comparison - elif record[field] != value: + # Convert both values to strings for comparison + recordValue = str(record[field]) + filterValue = str(value) + + # Direct string comparison + if recordValue != filterValue: match = False break @@ -223,7 +261,7 @@ class DatabaseConnector: return filteredRecords - def _registerInitialId(self, table: str, initialId: int) -> bool: + def _registerInitialId(self, table: str, initialId: str) -> bool: """Registers the initial ID for a table.""" try: systemData = self._loadSystemTable() @@ -255,6 +293,41 @@ class DatabaseConnector: logger.error(f"Error removing initial ID for table {table}: {e}") return False + def _getCurrentTimestamp(self) -> str: + """Returns the current timestamp in ISO format.""" + return datetime.now().isoformat() + + def _saveTableMetadata(self, table: str, metadata: Dict[str, Any]) -> bool: + """Saves table metadata to a metadata file.""" + try: + # Create metadata file path + metadataPath = os.path.join(self._getTablePath(table), "_metadata.json") + + # Save metadata + with open(metadataPath, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + + # Update cache + self._tableMetadataCache[table] = metadata + + return True + except Exception as e: + logger.error(f"Error saving metadata for table {table}: {e}") + return False + + def updateContext(self, _mandateId: str, _userId: str) -> None: + """Updates the context of the database connector.""" + if _mandateId is None or _userId is None: + raise ValueError("_mandateId and _userId must both be provided") + + self._mandateId = _mandateId + self._userId = _userId + logger.info(f"Updated database context: _mandateId={self._mandateId}, _userId={self._userId}") + + # Clear cache to ensure fresh data with new context + self._tablesCache = {} + self._tableMetadataCache = {} + # Public API def getTables(self) -> List[str]: @@ -262,10 +335,10 @@ class DatabaseConnector: tables = [] try: - for filename in os.listdir(self.dbFolder): - if filename.endswith('.json') and not filename.startswith('_'): - tableName = filename[:-5] # Remove the .json extension - tables.append(tableName) + for item in os.listdir(self.dbFolder): + itemPath = os.path.join(self.dbFolder, item) + if os.path.isdir(itemPath) and not item.startswith('_'): + tables.append(item) except Exception as e: logger.error(f"Error reading the database directory: {e}") @@ -306,16 +379,26 @@ class DatabaseConnector: def getRecordset(self, table: str, fieldFilter: List[str] = None, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]: """Returns a list of records from a table, filtered by criteria.""" - data = self._loadTable(table) + # If we have specific record IDs in the filter, only load those records + if recordFilter and "id" in recordFilter: + recordId = recordFilter["id"] + record = self._loadRecord(table, recordId) + if record: + records = [record] + else: + return [] + else: + # Load all records if no specific ID filter + records = self._loadTable(table) # Apply recordFilter if available if recordFilter: - data = self._applyRecordFilter(data, recordFilter) + records = self._applyRecordFilter(records, recordFilter) # If fieldFilter is available, reduce the fields if fieldFilter and isinstance(fieldFilter, list): result = [] - for record in data: + for record in records: filteredRecord = {} for field in fieldFilter: if field in record: @@ -323,92 +406,159 @@ class DatabaseConnector: result.append(filteredRecord) return result - return data + return records def recordCreate(self, table: str, recordData: Dict[str, Any]) -> Dict[str, Any]: - """Creates a new record in the table.""" - data = self._loadTable(table) - - # Add mandateId and userId if not present - if "mandateId" not in recordData or recordData["mandateId"] == 0: - recordData["mandateId"] = self.mandateId - - if "userId" not in recordData or recordData["userId"] == 0: - recordData["userId"] = self.userId - - # Determine the next ID if not present - if "id" not in recordData: - nextId = 1 - if data: - nextId = max(record["id"] for record in data if "id" in record) + 1 - recordData["id"] = nextId - - # If the table is empty and a system ID should be registered - if not data: - self._registerInitialId(table, recordData["id"]) - logger.info(f"Initial ID {recordData['id']} for table {table} has been registered") - - # Add the new record - data.append(recordData) - - # Save the updated table - if self._saveTable(table, data): + """Creates a new record in the specified table.""" + try: + # Ensure table directory exists + if not self._ensureTableDirectory(table): + raise ValueError(f"Error creating table directory for {table}") + + # Load table metadata + metadata = self._loadTableMetadata(table) + + # Generate new ID if not provided + if "id" not in recordData: + recordData["id"] = str(uuid.uuid4()) + else: + # Ensure ID is a string + recordData["id"] = str(recordData["id"]) + + # Add context fields + recordData["_mandateId"] = self._mandateId + recordData["_userId"] = self._userId + + # Update metadata + if "recordIds" not in metadata: + metadata["recordIds"] = [] + metadata["recordIds"].append(recordData["id"]) + metadata["recordIds"].sort() + + # Add creation timestamp + currentTime = self._getCurrentTimestamp() + recordData["_createdAt"] = currentTime + recordData["_modifiedAt"] = currentTime + + # Save the record + recordPath = self._getRecordPath(table, recordData["id"]) + os.makedirs(os.path.dirname(recordPath), exist_ok=True) + with open(recordPath, 'w', encoding='utf-8') as f: + json.dump(recordData, f, indent=2, ensure_ascii=False) + + # Save metadata + if not self._saveTableMetadata(table, metadata): + raise ValueError(f"Error saving metadata for table {table}") + + # Update cache safely + if table in self._tablesCache: + if isinstance(self._tablesCache[table], list): + self._tablesCache[table].append(recordData) + else: + self._tablesCache[table] = [recordData] + else: + self._tablesCache[table] = [recordData] + + # Verify the record was created + if not os.path.exists(recordPath): + raise ValueError(f"Record file was not created at {recordPath}") + return recordData - else: + + except Exception as e: + logger.error(f"Error creating record in table {table}: {str(e)}") raise ValueError(f"Error creating the record in table {table}") - def recordDelete(self, table: str, recordId: Union[str, int]) -> bool: + def recordDelete(self, table: str, recordId: str) -> bool: """Deletes a record from the table.""" - data = self._loadTable(table) + # Load metadata + metadata = self._loadTableMetadata(table) - # Search for the record - for i, record in enumerate(data): - if "id" in record and record["id"] == recordId: - # Check if it's an initial record - initialId = self.getInitialId(table) - if initialId is not None and initialId == recordId: - self._removeInitialId(table) - logger.info(f"Initial ID {recordId} for table {table} has been removed from the system table") - - # Delete the record - del data[i] - - # Save the updated table - return self._saveTable(table, data) + if recordId not in metadata["recordIds"]: + return False + + # Check if it's an initial record + initialId = self.getInitialId(table) + if initialId is not None and initialId == recordId: + self._removeInitialId(table) + logger.info(f"Initial ID {recordId} for table {table} has been removed from the system table") + + # Delete the record file + recordPath = self._getRecordPath(table, recordId) + try: + if os.path.exists(recordPath): + os.remove(recordPath) + + # Update metadata cache + metadata["recordIds"].remove(recordId) + self._tableMetadataCache[table] = metadata + + # Update table cache if it exists + if table in self._tablesCache: + self._tablesCache[table] = [r for r in self._tablesCache[table] if r.get("id") != recordId] + + return True + except Exception as e: + logger.error(f"Error deleting record file {recordPath}: {e}") + return False - # Record not found return False - def recordModify(self, table: str, recordId: Union[str, int], recordData: Dict[str, Any]) -> Dict[str, Any]: + def recordModify(self, table: str, recordId: str, recordData: Dict[str, Any]) -> Dict[str, Any]: """Modifies a record in the table.""" - data = self._loadTable(table) + # Ensure table directory exists + if not self._ensureTableDirectory(table): + raise ValueError(f"Error creating table directory for {table}") + + # Load metadata to check if record exists + metadata = self._loadTableMetadata(table) - # Search for the record - for i, record in enumerate(data): - if "id" in record and record["id"] == recordId: - # Prevent changing the ID - if "id" in recordData and recordData["id"] != recordId: - raise ValueError(f"The ID of a record in table {table} cannot be changed") - - # Update the record - for key, value in recordData.items(): - data[i][key] = value - - # Save the updated table - if self._saveTable(table, data): - return data[i] - else: - raise ValueError(f"Error updating record in table {table}") + # Ensure recordId is a string + recordId = str(recordId) - # Record not found - raise ValueError(f"Record with ID {recordId} not found in table {table}") + if recordId not in metadata["recordIds"]: + raise ValueError(f"Record with ID {recordId} not found in table {table}") + + # Prevent changing the ID + if "id" in recordData and str(recordData["id"]) != recordId: + raise ValueError(f"The ID of a record in table {table} cannot be changed") + + # Load existing record + existingRecord = self._loadRecord(table, recordId) + if not existingRecord: + raise ValueError(f"Record with ID {recordId} not found in table {table}") + + # Update the record + for key, value in recordData.items(): + existingRecord[key] = value + + # Update modified timestamp + existingRecord["_modifiedAt"] = self._getCurrentTimestamp() + + # Save the updated record + recordPath = self._getRecordPath(table, recordId) + try: + with open(recordPath, 'w', encoding='utf-8') as f: + json.dump(existingRecord, f, indent=2, ensure_ascii=False) + + # Update table cache if it exists + if table in self._tablesCache: + for i, record in enumerate(self._tablesCache[table]): + if str(record.get("id")) == recordId: + self._tablesCache[table][i] = existingRecord + break + + return existingRecord + except Exception as e: + logger.error(f"Error updating record file {recordPath}: {e}") + raise ValueError(f"Error updating record in table {table}") def hasInitialId(self, table: str) -> bool: """Checks if an initial ID is registered for a table.""" systemData = self._loadSystemTable() return table in systemData - def getInitialId(self, table: str) -> Optional[int]: + def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" systemData = self._loadSystemTable() initialId = systemData.get(table) @@ -417,7 +567,7 @@ class DatabaseConnector: logger.debug(f"No initial ID found for table {table}") return initialId - def getAllInitialIds(self) -> Dict[str, int]: + def getAllInitialIds(self) -> Dict[str, str]: """Returns all registered initial IDs.""" systemData = self._loadSystemTable() return systemData.copy() # Return a copy to protect the original \ No newline at end of file diff --git a/modules/interfaces/gatewayAccess.py b/modules/interfaces/gatewayAccess.py index bf93d58f..95e27a2e 100644 --- a/modules/interfaces/gatewayAccess.py +++ b/modules/interfaces/gatewayAccess.py @@ -5,7 +5,7 @@ Manages user access and permissions. from typing import Dict, Any, List, Optional -def _uam(currentUser: Dict[str, Any], table: str, recordset: List[Dict[str, Any]], mandateId: int, userId: int, db) -> List[Dict[str, Any]]: +def _uam(currentUser: Dict[str, Any], table: str, recordset: List[Dict[str, Any]], _mandateId: int, _userId: int, db) -> List[Dict[str, Any]]: """ Unified user access management function that filters data based on user privileges and adds access control attributes. @@ -14,8 +14,8 @@ def _uam(currentUser: Dict[str, Any], table: str, recordset: List[Dict[str, Any] currentUser: Current user information dictionary table: Name of the table recordset: Recordset to filter based on access rules - mandateId: Current mandate ID - userId: Current user ID + _mandateId: Current mandate ID + _userId: Current user ID db: Database connector instance Returns: @@ -29,11 +29,11 @@ def _uam(currentUser: Dict[str, Any], table: str, recordset: List[Dict[str, Any] filtered_records = recordset # System admins see all records elif userPrivilege == "admin": # Admins see records in their mandate - filtered_records = [r for r in recordset if r.get("mandateId") == mandateId] + filtered_records = [r for r in recordset if r.get("_mandateId") == _mandateId] else: # Regular users # Users only see records they own within their mandate filtered_records = [r for r in recordset - if r.get("mandateId") == mandateId and r.get("userId") == userId] + if r.get("_mandateId") == _mandateId and r.get("_userId") == _userId] # Add access control attributes to each record for record in filtered_records: @@ -42,21 +42,21 @@ def _uam(currentUser: Dict[str, Any], table: str, recordset: List[Dict[str, Any] # Set access control flags based on user permissions if table == "mandates": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not _canModify(currentUser, "mandates", record_id, mandateId, userId, db) - record["_hideDelete"] = not _canModify(currentUser, "mandates", record_id, mandateId, userId, db) + record["_hideEdit"] = not _canModify(currentUser, "mandates", record_id, _mandateId, _userId, db) + record["_hideDelete"] = not _canModify(currentUser, "mandates", record_id, _mandateId, _userId, db) elif table == "users": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not _canModify(currentUser, "users", record_id, mandateId, userId, db) - record["_hideDelete"] = not _canModify(currentUser, "users", record_id, mandateId, userId, db) + record["_hideEdit"] = not _canModify(currentUser, "users", record_id, _mandateId, _userId, db) + record["_hideDelete"] = not _canModify(currentUser, "users", record_id, _mandateId, _userId, db) else: # Default access control for other tables record["_hideView"] = False - record["_hideEdit"] = not _canModify(currentUser, table, record_id, mandateId, userId, db) - record["_hideDelete"] = not _canModify(currentUser, table, record_id, mandateId, userId, db) + record["_hideEdit"] = not _canModify(currentUser, table, record_id, _mandateId, _userId, db) + record["_hideDelete"] = not _canModify(currentUser, table, record_id, _mandateId, _userId, db) return filtered_records -def _canModify(currentUser: Dict[str, Any], table: str, recordId: Optional[int] = None, mandateId: int = None, userId: int = None, db = None) -> bool: +def _canModify(currentUser: Dict[str, Any], table: str, recordId: Optional[int] = None, _mandateId: int = None, _userId: int = None, db = None) -> bool: """ Checks if the current user can modify (create/update/delete) records in a table. @@ -64,8 +64,8 @@ def _canModify(currentUser: Dict[str, Any], table: str, recordId: Optional[int] currentUser: Current user information dictionary table: Name of the table recordId: Optional record ID for specific record check - mandateId: Current mandate ID - userId: Current user ID + _mandateId: Current mandate ID + _userId: Current user ID db: Database connector instance Returns: @@ -87,15 +87,15 @@ def _canModify(currentUser: Dict[str, Any], table: str, recordId: Optional[int] record = records[0] # Admins can modify anything in their mandate - if userPrivilege == "admin" and record.get("mandateId") == mandateId: + if userPrivilege == "admin" and record.get("_mandateId") == _mandateId: # Exception: Can't modify Root mandate unless you are a sysadmin if table == "mandates" and recordId == 1 and userPrivilege != "sysadmin": return False return True # Users can only modify their own records - if (record.get("mandateId") == mandateId and - record.get("userId") == userId): + if (record.get("_mandateId") == _mandateId and + record.get("_userId") == _userId): return True return False diff --git a/modules/interfaces/gatewayInterface.py b/modules/interfaces/gatewayInterface.py index 6ab523d4..7ed117b3 100644 --- a/modules/interfaces/gatewayInterface.py +++ b/modules/interfaces/gatewayInterface.py @@ -25,11 +25,11 @@ class GatewayInterface: Manages users and mandates. """ - def __init__(self, mandateId: int = None, userId: int = None): + def __init__(self, _mandateId: str = None, _userId: str = None): """Initializes the Gateway Interface with optional mandate and user context.""" # Context can be empty during initialization - self.mandateId = mandateId - self.userId = userId + self._mandateId = _mandateId + self._userId = _userId # Initialize database self._initializeDatabase() @@ -40,41 +40,47 @@ class GatewayInterface: # Initialize standard records if needed self._initRecords() - def _getCurrentUserInfo(self) -> Dict[str, Any]: - """Gets information about the current user including privileges.""" - # For initialization, set default values - userInfo = { - "id": self.userId, - "mandateId": self.mandateId, - "privilege": "user", # Default privilege level - "language": "en" - } - - # Try to load actual user info if IDs are provided - if self.userId: - userRecords = self.db.getRecordset("users", recordFilter={"id": self.userId}) - if userRecords: - user = userRecords[0] - userInfo["privilege"] = user.get("privilege", "user") - userInfo["language"] = user.get("language", "en") - - return userInfo - def _initializeDatabase(self): """Initializes the database connection.""" + # Get configuration values with defaults + dbHost = APP_CONFIG.get("DB_GATEWAY_HOST", "data") + dbDatabase = APP_CONFIG.get("DB_GATEWAY_DATABASE", "gateway") + dbUser = APP_CONFIG.get("DB_GATEWAY_USER") + dbPassword = APP_CONFIG.get("DB_GATEWAY_PASSWORD_SECRET") + + # Ensure the database directory exists + os.makedirs(dbHost, exist_ok=True) + self.db = DatabaseConnector( - dbHost=APP_CONFIG.get("DB_SYSTEM_HOST"), - dbDatabase=APP_CONFIG.get("DB_SYSTEM_DATABASE"), - dbUser=APP_CONFIG.get("DB_SYSTEM_USER"), - dbPassword=APP_CONFIG.get("DB_SYSTEM_PASSWORD_SECRET"), - mandateId=self.mandateId if self.mandateId else 0, - userId=self.userId if self.userId else 0 + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + _mandateId=self._mandateId, + _userId=self._userId ) - + + def _getCurrentUserInfo(self) -> Optional[Dict[str, Any]]: + """Returns information about the current user.""" + if not self._userId: + return None + + users = self.db.getRecordset("users", recordFilter={"id": self._userId}) + if users: + return users[0] + return None + def _initRecords(self): """Initializes standard records in the database if they don't exist.""" self._initRootMandate() self._initAdminUser() + + # Update database context with new IDs + if self._mandateId and self._userId: + self.db.updateContext(self._mandateId, self._userId) + + # Reload user information with new context + self.currentUser = self._getCurrentUserInfo() def _initRootMandate(self): """Creates the Root mandate if it doesn't exist.""" @@ -89,8 +95,11 @@ class GatewayInterface: createdMandate = self.db.recordCreate("mandates", rootMandate) logger.info(f"Root mandate created with ID {createdMandate['id']}") + # Register the initial ID + self.db._registerInitialId("mandates", createdMandate['id']) + # Update mandate context - self.mandateId = createdMandate['id'] + self._mandateId = createdMandate['id'] def _initAdminUser(self): """Creates the Admin user if it doesn't exist.""" @@ -99,7 +108,7 @@ class GatewayInterface: if existingUserId is None or not users: logger.info("Creating Admin user") adminUser = { - "mandateId": self.mandateId, + "_mandateId": self._mandateId, "username": "admin", "email": "admin@example.com", "fullName": "Administrator", @@ -111,8 +120,11 @@ class GatewayInterface: createdUser = self.db.recordCreate("users", adminUser) logger.info(f"Admin user created with ID {createdUser['id']}") + # Register the initial ID + self.db._registerInitialId("users", createdUser['id']) + # Update user context - self.userId = createdUser['id'] + self._userId = createdUser['id'] def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ @@ -126,9 +138,9 @@ class GatewayInterface: Returns: Filtered recordset with access control attributes """ - return _uam(self.currentUser, table, recordset, self.mandateId, self.userId, self.db) + return _uam(self.currentUser, table, recordset, self._mandateId, self._userId, self.db) - def _canModify(self, table: str, recordId: Optional[int] = None) -> bool: + def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """ Checks if the current user can modify (create/update/delete) records in a table. @@ -139,9 +151,9 @@ class GatewayInterface: Returns: Boolean indicating permission """ - return _canModify(self.currentUser, table, recordId, self.mandateId, self.userId, self.db) + return _canModify(self.currentUser, table, recordId, self._mandateId, self._userId, self.db) - def getInitialId(self, table: str) -> Optional[int]: + def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(table) @@ -165,7 +177,7 @@ class GatewayInterface: allMandates = self.db.getRecordset("mandates") return self._uam("mandates", allMandates) - def getMandate(self, mandateId: int) -> Optional[Dict[str, Any]]: + def getMandate(self, mandateId: str) -> Optional[Dict[str, Any]]: """Returns a mandate by ID if user has access.""" mandates = self.db.getRecordset("mandates", recordFilter={"id": mandateId}) if not mandates: @@ -186,7 +198,7 @@ class GatewayInterface: return self.db.recordCreate("mandates", mandateData) - def updateMandate(self, mandateId: int, mandateData: Dict[str, Any]) -> Dict[str, Any]: + def updateMandate(self, mandateId: str, mandateData: Dict[str, Any]) -> Dict[str, Any]: """Updates a mandate if user has access.""" # Check if the mandate exists and user has access mandate = self.getMandate(mandateId) @@ -199,7 +211,7 @@ class GatewayInterface: # Update the mandate return self.db.recordModify("mandates", mandateId, mandateData) - def deleteMandate(self, mandateId: int) -> bool: + def deleteMandate(self, mandateId: str) -> bool: """ Deletes a mandate and all associated users and data if user has permission. """ @@ -248,15 +260,15 @@ class GatewayInterface: return filteredUsers - def getUsersByMandate(self, mandateId: int) -> List[Dict[str, Any]]: + def getUsersByMandate(self, _mandateId: str) -> List[Dict[str, Any]]: """Returns users for a specific mandate if user has access.""" # First check if user has access to the mandate - mandate = self.getMandate(mandateId) + mandate = self.getMandate(_mandateId) if not mandate: return [] # Get users for this mandate - users = self.db.getRecordset("users", recordFilter={"mandateId": mandateId}) + users = self.db.getRecordset("users", recordFilter={"_mandateId": _mandateId}) filteredUsers = self._uam("users", users) # Remove password hashes @@ -268,6 +280,7 @@ class GatewayInterface: def getUserByUsername(self, username: str) -> Optional[Dict[str, Any]]: """Returns a user by username.""" + # Get all users without mandate filter users = self.db.getRecordset("users") for user in users: if user.get("username") == username: @@ -278,9 +291,9 @@ class GatewayInterface: logger.debug(f"No user found with username {username}") return None - def getUser(self, userId: int) -> Optional[Dict[str, Any]]: + def getUser(self, _userId: str) -> Optional[Dict[str, Any]]: """Returns a user by ID if user has access.""" - users = self.db.getRecordset("users", recordFilter={"id": userId}) + users = self.db.getRecordset("users", recordFilter={"_userId": _userId}) if not users: return None @@ -299,31 +312,58 @@ class GatewayInterface: return user def createUser(self, username: str, password: str, email: str = None, - fullName: str = None, language: str = "de", mandateId: int = None, + fullName: str = None, language: str = "de", _mandateId: str = None, disabled: bool = False, privilege: str = "user") -> Dict[str, Any]: """Creates a new user if current user has permission.""" + # Validate username + if not username or len(username) < 3: + raise ValueError("Benutzername muss mindestens 3 Zeichen lang sein") + + # Validate password + if not password: + raise ValueError("Passwort ist erforderlich") + + # Password requirements + if len(password) < 8: + raise ValueError("Passwort muss mindestens 8 Zeichen lang sein") + if not any(c.isupper() for c in password): + raise ValueError("Passwort muss mindestens einen Grossbuchstaben enthalten") + if not any(c.islower() for c in password): + raise ValueError("Passwort muss mindestens einen Kleinbuchstaben enthalten") + if not any(c.isdigit() for c in password): + raise ValueError("Passwort muss mindestens eine Zahl enthalten") + if not any(c in "!@#$%^&*(),.?\":{}|<>" for c in password): + raise ValueError("Passwort muss mindestens ein Sonderzeichen enthalten") + + # Validate email if provided + if email: + import re + email_pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$' + if not re.match(email_pattern, email): + raise ValueError("Ungültiges E-Mail-Format") + # Check if the username already exists existingUser = self.getUserByUsername(username) if existingUser: - raise ValueError(f"User '{username}' already exists") + raise ValueError(f"Benutzer '{username}' existiert bereits") - # Use the provided mandateId or the current context - userMandateId = mandateId if mandateId is not None else self.mandateId + # Use the provided _mandateId or the current context + userMandateId = _mandateId if _mandateId is not None else self._mandateId # Check if user has access to the mandate - if userMandateId != self.mandateId and self.currentUser.get("privilege") != "sysadmin": - raise PermissionError(f"No permission to create users in mandate {userMandateId}") + if userMandateId != self._mandateId and self.currentUser.get("privilege") != "sysadmin": + raise PermissionError(f"Keine Berechtigung, Benutzer in Mandat {userMandateId} zu erstellen") if not self._canModify("users"): - raise PermissionError("No permission to create users") + raise PermissionError("Keine Berechtigung, Benutzer zu erstellen") # Check privilege escalation if (privilege == "sysadmin" or (privilege == "admin" and self.currentUser.get("privilege") == "user")): - raise PermissionError(f"Cannot create user with higher privilege: {privilege}") + raise PermissionError(f"Keine Berechtigung, Benutzer mit höherem Privileg zu erstellen: {privilege}") userData = { - "mandateId": userMandateId, + "_mandateId": userMandateId, "username": username, "email": email, "fullName": fullName, @@ -335,6 +375,10 @@ class GatewayInterface: createdUser = self.db.recordCreate("users", userData) + # Clear the users table from cache to ensure fresh data + if "users" in self.db._tablesCache: + del self.db._tablesCache["users"] + # Return the complete user record return createdUser @@ -344,9 +388,8 @@ class GatewayInterface: if "users" in self.db._tablesCache: del self.db._tablesCache["users"] - # Get fresh user data - users = self.db.getRecordset("users") - user = next((u for u in users if u.get("username") == username), None) + # Get user by username + user = self.getUserByUsername(username) if not user: raise ValueError("Benutzer nicht gefunden") @@ -366,19 +409,19 @@ class GatewayInterface: return authenticatedUser - def updateUser(self, userId: int, userData: Dict[str, Any]) -> Dict[str, Any]: + def updateUser(self, _userId: str, userData: Dict[str, Any]) -> Dict[str, Any]: """Updates a user if current user has permission.""" # Check if the user exists and current user has access - user = self.getUser(userId) + user = self.getUser(_userId) if not user: # Try to get the raw user record for admin access check - users = self.db.getRecordset("users", recordFilter={"id": userId}) + users = self.db.getRecordset("users", recordFilter={"_userId": _userId}) if not users: - raise ValueError(f"User with ID {userId} not found") + raise ValueError(f"User with ID {_userId} not found") # Check if current user is admin/sysadmin - if not self._canModify("users", userId): - raise PermissionError(f"No permission to update user {userId}") + if not self._canModify("users", _userId): + raise PermissionError(f"No permission to update user {_userId}") user = users[0] @@ -397,7 +440,7 @@ class GatewayInterface: del userData["password"] # Update the user - updatedUser = self.db.recordModify("users", userId, userData) + updatedUser = self.db.recordModify("users", _userId, userData) # Remove password hash from the response if "hashedPassword" in updatedUser: @@ -405,53 +448,53 @@ class GatewayInterface: return updatedUser - def disableUser(self, userId: int) -> Dict[str, Any]: + def disableUser(self, _userId: str) -> Dict[str, Any]: """Disables a user if current user has permission.""" - return self.updateUser(userId, {"disabled": True}) + return self.updateUser(_userId, {"disabled": True}) - def enableUser(self, userId: int) -> Dict[str, Any]: + def enableUser(self, _userId: str) -> Dict[str, Any]: """Enables a user if current user has permission.""" - return self.updateUser(userId, {"disabled": False}) + return self.updateUser(_userId, {"disabled": False}) - def _deleteUserReferencedData(self, userId: int) -> None: + def _deleteUserReferencedData(self, _userId: str) -> None: """Deletes all data associated with a user.""" # Delete user attributes try: - attributes = self.db.getRecordset("attributes", recordFilter={"userId": userId}) + attributes = self.db.getRecordset("attributes", recordFilter={"_userId": _userId}) for attribute in attributes: self.db.recordDelete("attributes", attribute["id"]) except Exception as e: - logger.error(f"Error deleting attributes for user {userId}: {e}") + logger.error(f"Error deleting attributes for user {_userId}: {e}") - logger.info(f"All referenced data for user {userId} has been deleted") + logger.info(f"All referenced data for user {_userId} has been deleted") - def deleteUser(self, userId: int) -> bool: + def deleteUser(self, _userId: str) -> bool: """Deletes a user and all associated data if current user has permission.""" # Check if the user exists - users = self.db.getRecordset("users", recordFilter={"id": userId}) + users = self.db.getRecordset("users", recordFilter={"_userId": _userId}) if not users: return False # Check if current user has permission - if not self._canModify("users", userId): - raise PermissionError(f"No permission to delete user {userId}") + if not self._canModify("users", _userId): + raise PermissionError(f"No permission to delete user {_userId}") # Check if it's the initial user initialUserId = self.getInitialId("users") - if initialUserId is not None and userId == initialUserId: + if initialUserId is not None and _userId == initialUserId: logger.warning("Attempt to delete the Root Admin was prevented") return False # Delete all data associated with the user - self._deleteUserReferencedData(userId) + self._deleteUserReferencedData(_userId) # Delete the user - success = self.db.recordDelete("users", userId) + success = self.db.recordDelete("users", _userId) if success: - logger.info(f"User with ID {userId} was successfully deleted") + logger.info(f"User with ID {_userId} was successfully deleted") else: - logger.error(f"Error deleting user with ID {userId}") + logger.error(f"Error deleting user with ID {_userId}") return success @@ -459,15 +502,16 @@ class GatewayInterface: # Singleton factory for GatewayInterface instances per context _gatewayInterfaces = {} -def getGatewayInterface(mandateId: int = None, userId: int = None) -> GatewayInterface: +def getGatewayInterface(_mandateId: str = None, _userId: str = None) -> GatewayInterface: """ Returns a GatewayInterface instance for the specified context. Reuses existing instances. """ - contextKey = f"{mandateId}_{userId}" + # For initialization, use empty strings instead of None + contextKey = f"{_mandateId or ''}_{_userId or ''}" if contextKey not in _gatewayInterfaces: - _gatewayInterfaces[contextKey] = GatewayInterface(mandateId, userId) + _gatewayInterfaces[contextKey] = GatewayInterface(_mandateId or '', _userId or '') return _gatewayInterfaces[contextKey] -# Initialize an instance -getGatewayInterface() \ No newline at end of file +# Initialize an instance with empty strings +getGatewayInterface('', '') \ No newline at end of file diff --git a/modules/interfaces/gatewayModel.py b/modules/interfaces/gatewayModel.py index 83d759c7..86f371bc 100644 --- a/modules/interfaces/gatewayModel.py +++ b/modules/interfaces/gatewayModel.py @@ -4,6 +4,7 @@ Data models for the gateway system. from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional from datetime import datetime +import uuid class Label(BaseModel): @@ -20,7 +21,7 @@ class Label(BaseModel): class Mandate(BaseModel): """Data model for a mandate""" - id: int = Field(description="Unique ID of the mandate") + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the mandate") name: str = Field(description="Name of the mandate") language: str = Field(description="Default language of the mandate") @@ -38,8 +39,7 @@ class Mandate(BaseModel): class User(BaseModel): """Data model for a user""" - id: int = Field(description="Unique ID of the user") - mandateId: int = Field(description="ID of the associated mandate") + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user") username: str = Field(description="Username for login") email: Optional[str] = Field(None, description="Email address of the user") fullName: Optional[str] = Field(None, description="Full name of the user") @@ -99,5 +99,5 @@ class Token(BaseModel): class TokenData(BaseModel): """Data for token decoding and validation""" username: Optional[str] = None - mandateId: Optional[int] = None + mandateId: Optional[str] = None exp: Optional[datetime] = None \ No newline at end of file diff --git a/modules/interfaces/lucydomAccess.py b/modules/interfaces/lucydomAccess.py index 2c018c52..0db95a88 100644 --- a/modules/interfaces/lucydomAccess.py +++ b/modules/interfaces/lucydomAccess.py @@ -11,11 +11,11 @@ class LucyDOMAccess: Handles user access management and permission checks. """ - def __init__(self, currentUser: Dict[str, Any], mandateId: int, userId: int): + def __init__(self, currentUser: Dict[str, Any], _mandateId: int, _userId: int): """Initialize with user context.""" self.currentUser = currentUser - self.mandateId = mandateId - self.userId = userId + self._mandateId = _mandateId + self._userId = _userId def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ @@ -37,19 +37,15 @@ class LucyDOMAccess: filtered_records = recordset # System admins see all records elif userPrivilege == "admin": # Admins see records in their mandate - filtered_records = [r for r in recordset if r.get("mandateId") == self.mandateId] + filtered_records = [r for r in recordset if r.get("_mandateId") == self._mandateId] else: # Regular users - # To see all prompts from mandate 0 and own + # For prompts, users can see all prompts from their mandate if table == "prompts": - filtered_records = [r for r in recordset if - (r.get("mandateId") == self.mandateId and r.get("userId") == self.userId) - or - (r.get("mandateId") == 0) - ] + filtered_records = [r for r in recordset if r.get("_mandateId") == self._mandateId] else: - # Users see only their records + # Users see only their records for other tables filtered_records = [r for r in recordset - if r.get("mandateId") == self.mandateId and r.get("userId") == self.userId] + if r.get("_mandateId") == self._mandateId and r.get("_userId") == self._userId] # Add access control attributes to each record for record in filtered_records: @@ -58,8 +54,14 @@ class LucyDOMAccess: # Set access control flags based on user permissions if table == "prompts": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not self._canModify("prompts", record_id) - record["_hideDelete"] = not self._canModify("prompts", record_id) + # Only allow modification of own prompts or if admin/sysadmin + can_modify = ( + userPrivilege == "sysadmin" or + (userPrivilege == "admin" and record.get("_mandateId") == self._mandateId) or + (record.get("_mandateId") == self._mandateId and record.get("_userId") == self._userId) + ) + record["_hideEdit"] = not can_modify + record["_hideDelete"] = not can_modify elif table == "files": record["_hideView"] = False # Everyone can view record["_hideEdit"] = not self._canModify("files", record_id) @@ -112,12 +114,12 @@ class LucyDOMAccess: record = records[0] # Admins can modify anything in their mandate - if userPrivilege == "admin" and record.get("mandateId") == self.mandateId: + if userPrivilege == "admin" and record.get("_mandateId") == self._mandateId: return True # Regular users can only modify their own records - if (record.get("mandateId") == self.mandateId and - record.get("userId") == self.userId): + if (record.get("_mandateId") == self._mandateId and + record.get("_userId") == self._userId): return True return False diff --git a/modules/interfaces/lucydomInterface.py b/modules/interfaces/lucydomInterface.py index 21214117..0b745ab3 100644 --- a/modules/interfaces/lucydomInterface.py +++ b/modules/interfaces/lucydomInterface.py @@ -24,6 +24,24 @@ from modules.connectors.connectorAiOpenai import ChatService from modules.shared.configuration import APP_CONFIG logger = logging.getLogger(__name__) +# Initialize AI service at module level +_aiService = None + +def initializeAIService(): + """Initialize the AI service for the LucyDOM interface.""" + global _aiService + if _aiService is None: + try: + _aiService = ChatService() + logger.info("AI service initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize AI service: {str(e)}") + _aiService = None + return _aiService + +# Initialize AI service when module is imported +initializeAIService() + # Custom exceptions for file handling class FileError(Exception): """Base class for file handling exceptions.""" @@ -45,6 +63,7 @@ class FileDeletionError(FileError): """Exception raised when there's an error deleting a file.""" pass +from modules.security.auth import getInitialContext class LucyDOMInterface: """ @@ -52,14 +71,19 @@ class LucyDOMInterface: Uses the JSON connector for data access. """ - def __init__(self, mandateId: int, userId: int): + def __init__(self, _mandateId: str, _userId: str): """Initializes the LucyDOM Interface with mandate and user context.""" - self.mandateId = mandateId - self.userId = userId + logger.debug(f"Initializing LucyDOMInterface with mandateId={_mandateId}, userId={_userId}") + self._mandateId = _mandateId + self._userId = _userId # Add language settings self.userLanguage = "en" # Default user language - self.aiService = None # Will be set externally + + # Set AI service from module-level instance + self.aiService = _aiService + if not self.aiService: + logger.warning("AI service not available during LucyDOMInterface initialization") # Initialize database connector self._initializeDatabase() @@ -68,10 +92,22 @@ class LucyDOMInterface: self.currentUser = self._getCurrentUserInfo() # Initialize access control - self.access = LucyDOMAccess(self.currentUser, self.mandateId, self.userId) + self.access = LucyDOMAccess(self.currentUser, self._mandateId, self._userId) self.access.db = self.db # Share database connection - # Initialize standard database records if needed + # Get initial IDs if not provided + if not self._mandateId or not self._userId: + logger.debug("No context provided, getting initial context from auth") + self._mandateId, self._userId = getInitialContext() + logger.debug(f"Retrieved initial context: mandate={self._mandateId}, user={self._userId}") + + if self._mandateId and self._userId: + self.db.updateContext(self._mandateId, self._userId) + logger.debug(f"Updated database context with initial IDs") + else: + logger.warning("No initial context available from auth") + + # Initialize standard records if needed self._initRecords() def _getCurrentUserInfo(self) -> Dict[str, Any]: @@ -79,74 +115,64 @@ class LucyDOMInterface: # For production, you would get this from authentication # For now return basic user info with default privilege return { - "id": self.userId, - "mandateId": self.mandateId, + "id": self._userId, + "_mandateId": self._mandateId, "privilege": "user", # Default privilege level "language": self.userLanguage } def _initializeDatabase(self): """Initializes the database connection.""" - effectiveMandateId = self.mandateId - effectiveUserId = self.userId - if effectiveMandateId is None or effectiveUserId is None: - return - self.db = DatabaseConnector( dbHost=APP_CONFIG.get("DB_LUCYDOM_HOST"), dbDatabase=APP_CONFIG.get("DB_LUCYDOM_DATABASE"), dbUser=APP_CONFIG.get("DB_LUCYDOM_USER"), dbPassword=APP_CONFIG.get("DB_LUCYDOM_PASSWORD_SECRET"), - mandateId=self.mandateId, - userId=self.userId, - skipInitialIdLookup=True + _mandateId=self._mandateId, + _userId=self._userId, + skipInitialIdLookup=True ) def _initRecords(self): """Initializes standard records in the database if they don't exist.""" - self._initializeStandardPrompts() + # Only initialize prompts if we have valid context + if self._mandateId and self._userId: + logger.debug(f"Initializing prompts with context: mandate={self._mandateId}, user={self._userId}") + self._initializeStandardPrompts() + else: + logger.warning("Skipping prompt initialization - no valid context available") def _initializeStandardPrompts(self): """Creates standard prompts if they don't exist.""" prompts = self.db.getRecordset("prompts") + logger.debug(f"Found {len(prompts)} existing prompts") + if not prompts: - logger.info("Creating standard prompts") + logger.debug("Creating standard prompts") # Define standard prompts standardPrompts = [ { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Research the current market trends and developments in [TOPIC]. Collect information about leading companies, innovative products or services, and current challenges. Present the results in a structured overview with relevant data and sources.", "name": "Web Research: Market Research" }, { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Analyze the attached dataset on [TOPIC] and identify the most important trends, patterns, and anomalies. Perform statistical calculations to support your findings. Present the results in a clearly structured analysis and draw relevant conclusions.", "name": "Analysis: Data Analysis" }, { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Create a detailed protocol of our meeting on [TOPIC]. Capture all discussed points, decisions made, and agreed measures. Structure the protocol clearly with agenda items, participant list, and clear responsibilities for follow-up actions.", "name": "Protocol: Meeting Minutes" }, { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Develop a UI/UX design concept for [APPLICATION/WEBSITE]. Consider the target audience, main functions, and brand identity. Describe the visual design, navigation, interaction patterns, and information architecture. Explain how the design optimizes user-friendliness and user experience.", "name": "Design: UI/UX Design" }, { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Gib mir die ersten 1000 Primzahlen", "name": "Code: Primzahlen" }, { - "mandateId": self.mandateId, - "userId": self.userId, "content": "Bereite mir eine formelle E-Mail an peter.muster@domain.com vor, um meinen Termin von 10 Uhr auf Freitag zu scheiben.", "name": "Mail: Vorbereitung" }, @@ -155,13 +181,15 @@ class LucyDOMInterface: # Create prompts for promptData in standardPrompts: createdPrompt = self.db.recordCreate("prompts", promptData) - logger.info(f"Prompt '{promptData.get('name', 'Standard')}' was created with ID {createdPrompt['id']}") + logger.debug(f"Prompt '{promptData.get('name', 'Standard')}' was created with ID {createdPrompt['id']} and context mandate={createdPrompt.get('_mandateId')}, user={createdPrompt.get('_userId')}") + else: + logger.debug("Prompts already exist, skipping creation") def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Delegate to access control module.""" return self.access._uam(table, recordset) - def _canModify(self, table: str, recordId: Optional[int] = None) -> bool: + def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """Delegate to access control module.""" return self.access._canModify(table, recordId) @@ -170,7 +198,7 @@ class LucyDOMInterface: def setUserLanguage(self, languageCode: str): """Set the user's preferred language""" self.userLanguage = languageCode - logger.info(f"User language set to: {languageCode}") + logger.debug(f"User language set to: {languageCode}") # AI Call Root Function @@ -208,7 +236,7 @@ class LucyDOMInterface: # Utilities - def getInitialId(self, table: str) -> Optional[int]: + def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(table) @@ -223,7 +251,7 @@ class LucyDOMInterface: allPrompts = self.db.getRecordset("prompts") return self._uam("prompts", allPrompts) - def getPrompt(self, promptId: int) -> Optional[Dict[str, Any]]: + def getPrompt(self, promptId: str) -> Optional[Dict[str, Any]]: """Returns a prompt by ID if user has access.""" prompts = self.db.getRecordset("prompts", recordFilter={"id": promptId}) if not prompts: @@ -238,8 +266,6 @@ class LucyDOMInterface: raise PermissionError("No permission to create prompts") promptData = { - "mandateId": self.mandateId, - "userId": self.userId, "content": content, "name": name, "createdAt": self._getCurrentTimestamp() @@ -247,7 +273,7 @@ class LucyDOMInterface: return self.db.recordCreate("prompts", promptData) - def updatePrompt(self, promptId: int, content: str = None, name: str = None) -> Dict[str, Any]: + def updatePrompt(self, promptId: str, content: str = None, name: str = None) -> Dict[str, Any]: """Updates a prompt if user has access.""" # Check if the prompt exists and user has access prompt = self.getPrompt(promptId) @@ -268,7 +294,7 @@ class LucyDOMInterface: # Update prompt return self.db.recordModify("prompts", promptId, promptData) - def deletePrompt(self, promptId: int) -> bool: + def deletePrompt(self, promptId: str) -> bool: """Deletes a prompt if user has access.""" # Check if the prompt exists and user has access prompt = self.getPrompt(promptId) @@ -290,8 +316,8 @@ class LucyDOMInterface: """Checks if a file with the same hash already exists for the current user and mandate.""" files = self.db.getRecordset("files", recordFilter={ "fileHash": fileHash, - "mandateId": self.mandateId, - "userId": self.userId + "_mandateId": self._mandateId, + "_userId": self._userId }) if files: return files[0] @@ -334,7 +360,7 @@ class LucyDOMInterface: allFiles = self.db.getRecordset("files") return self._uam("files", allFiles) - def getFile(self, fileId: int) -> Optional[Dict[str, Any]]: + def getFile(self, fileId: str) -> Optional[Dict[str, Any]]: """Returns a file by ID if user has access.""" files = self.db.getRecordset("files", recordFilter={"id": fileId}) if not files: @@ -349,8 +375,8 @@ class LucyDOMInterface: raise PermissionError("No permission to create files") fileData = { - "mandateId": self.mandateId, - "userId": self.userId, + "_mandateId": self._mandateId, + "_userId": self._userId, "name": name, "mimeType": mimeType, "size": size, @@ -359,7 +385,7 @@ class LucyDOMInterface: } return self.db.recordCreate("files", fileData) - def updateFile(self, fileId: int, updateData: Dict[str, Any]) -> Dict[str, Any]: + def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: """Updates file metadata if user has access.""" # Check if the file exists and user has access file = self.getFile(fileId) @@ -372,7 +398,7 @@ class LucyDOMInterface: # Update file return self.db.recordModify("files", fileId, updateData) - def deleteFile(self, fileId: int) -> bool: + def deleteFile(self, fileId: str) -> bool: """Deletes a file if user has access.""" try: # Check if the file exists and user has access @@ -396,7 +422,7 @@ class LucyDOMInterface: fileDataEntries = self.db.getRecordset("fileData", recordFilter={"id": fileId}) if fileDataEntries: self.db.recordDelete("fileData", fileId) - logger.info(f"FileData for file {fileId} deleted") + logger.debug(f"FileData for file {fileId} deleted") except Exception as e: logger.warning(f"Error deleting FileData for file {fileId}: {str(e)}") @@ -413,7 +439,7 @@ class LucyDOMInterface: # FileData methods - data operations - def createFileData(self, fileId: int, data: bytes) -> bool: + def createFileData(self, fileId: str, data: bytes) -> bool: """Stores the binary data of a file in the database.""" try: import base64 @@ -459,13 +485,13 @@ class LucyDOMInterface: } self.db.recordCreate("fileData", fileDataObj) - logger.info(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})") + logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})") return True except Exception as e: logger.error(f"Error storing data for file {fileId}: {str(e)}") return False - def getFileData(self, fileId: int) -> Optional[bytes]: + def getFileData(self, fileId: str) -> Optional[bytes]: """Returns the binary data of a file if user has access.""" # Check file access file = self.getFile(fileId) @@ -499,7 +525,7 @@ class LucyDOMInterface: logger.error(f"Error processing file data for {fileId}: {str(e)}") return None - def updateFileData(self, fileId: int, data: Union[bytes, str]) -> bool: + def updateFileData(self, fileId: str, data: Union[bytes, str]) -> bool: """Updates file data if user has access.""" # Check file access file = self.getFile(fileId) @@ -573,12 +599,12 @@ class LucyDOMInterface: if fileDataEntries: # Update the existing record self.db.recordModify("fileData", fileId, dataUpdate) - logger.info(f"Updated file data for file ID {fileId} (base64Encoded: {base64Encoded})") + logger.debug(f"Updated file data for file ID {fileId} (base64Encoded: {base64Encoded})") else: # Create a new record dataUpdate["id"] = fileId self.db.recordCreate("fileData", dataUpdate) - logger.info(f"Created new file data for file ID {fileId} (base64Encoded: {base64Encoded})") + logger.debug(f"Created new file data for file ID {fileId} (base64Encoded: {base64Encoded})") return True except Exception as e: @@ -592,7 +618,7 @@ class LucyDOMInterface: if not self._canModify("files"): raise PermissionError("No permission to upload files") - logger.info(f"Starting upload process for file: {fileName}") + logger.debug(f"Starting upload process for file: {fileName}") if not isinstance(fileContent, bytes): logger.error(f"Invalid fileContent type: {type(fileContent)}") @@ -605,7 +631,7 @@ class LucyDOMInterface: # Check for duplicate within same user/mandate existingFile = self.checkForDuplicateFile(fileHash) if existingFile: - logger.info(f"Duplicate found for {fileName}: {existingFile['id']}") + logger.debug(f"Duplicate found for {fileName}: {existingFile['id']}") return existingFile # Determine MIME type and size @@ -613,7 +639,7 @@ class LucyDOMInterface: fileSize = len(fileContent) # Save metadata - logger.info(f"Saving file metadata to database for file: {fileName}") + logger.debug(f"Saving file metadata to database for file: {fileName}") dbFile = self.createFile( name=fileName, mimeType=mimeType, @@ -622,17 +648,17 @@ class LucyDOMInterface: ) # Save binary data - logger.info(f"Saving file content to database for file: {fileName}") + logger.debug(f"Saving file content to database for file: {fileName}") self.createFileData(dbFile["id"], fileContent) - logger.info(f"File upload process completed for: {fileName}") + logger.debug(f"File upload process completed for: {fileName}") return dbFile except Exception as e: logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True) raise FileStorageError(f"Error saving file: {str(e)}") - def downloadFile(self, fileId: int) -> Optional[Dict[str, Any]]: + def downloadFile(self, fileId: str) -> Optional[Dict[str, Any]]: """Returns a file for download if user has access.""" try: # Check file access @@ -667,10 +693,10 @@ class LucyDOMInterface: allWorkflows = self.db.getRecordset("workflows") return self._uam("workflows", allWorkflows) - def getWorkflowsByUser(self, userId: int) -> List[Dict[str, Any]]: + def getWorkflowsByUser(self, _userId: str) -> List[Dict[str, Any]]: """Returns workflows for a specific user if current user has access.""" - # Get workflows by userId - workflows = self.db.getRecordset("workflows", recordFilter={"userId": userId}) + # Get workflows by _userId + workflows = self.db.getRecordset("workflows", recordFilter={"_userId": _userId}) # Apply access control return self._uam("workflows", workflows) @@ -690,11 +716,11 @@ class LucyDOMInterface: raise PermissionError("No permission to create workflows") # Make sure mandateId and userId are set - if "mandateId" not in workflowData: - workflowData["mandateId"] = self.mandateId + if "_mandateId" not in workflowData: + workflowData["_mandateId"] = self._mandateId - if "userId" not in workflowData: - workflowData["userId"] = self.userId + if "_userId" not in workflowData: + workflowData["_userId"] = self._userId # Set timestamp if not present currentTime = self._getCurrentTimestamp() @@ -877,7 +903,7 @@ class LucyDOMInterface: # Update the message updatedMessage = self.db.recordModify("workflowMessages", messageId, messageData) if updatedMessage: - logger.info(f"Message {messageId} updated successfully") + logger.debug(f"Message {messageId} updated successfully") else: logger.warning(f"Failed to update message {messageId}") @@ -912,7 +938,7 @@ class LucyDOMInterface: logger.error(f"Error deleting message {messageId}: {str(e)}") return False - def deleteFileFromMessage(self, workflowId: str, messageId: str, fileId: int) -> bool: + def deleteFileFromMessage(self, workflowId: str, messageId: str, fileId: str) -> bool: """Removes a file reference from a message if user has access.""" try: # Check workflow access @@ -924,7 +950,7 @@ class LucyDOMInterface: if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") - logger.info(f"Removing file {fileId} from message {messageId} in workflow {workflowId}") + logger.debug(f"Removing file {fileId} from message {messageId} in workflow {workflowId}") # Get all workflow messages allMessages = self.getWorkflowMessages(workflowId) @@ -951,7 +977,7 @@ class LucyDOMInterface: return False # Log the found message - logger.info(f"Found message: {message.get('id')}") + logger.debug(f"Found message: {message.get('id')}") # Check if message has documents if "documents" not in message or not message["documents"]: @@ -980,7 +1006,7 @@ class LucyDOMInterface: if shouldRemove: removed = True - logger.info(f"Found file to remove: docId={docId}, fileId={fileIdValue}") + logger.debug(f"Found file to remove: docId={docId}, fileId={fileIdValue}") else: updatedDocuments.append(doc) @@ -997,7 +1023,7 @@ class LucyDOMInterface: updated = self.db.recordModify("workflowMessages", message["id"], messageUpdate) if updated: - logger.info(f"Successfully removed file {fileId} from message {messageId}") + logger.debug(f"Successfully removed file {fileId} from message {messageId}") return True else: logger.warning(f"Failed to update message {messageId} in database") @@ -1081,8 +1107,8 @@ class LucyDOMInterface: # Extract only the database-relevant workflow fields workflowDbData = { "id": workflowId, - "mandateId": workflow.get("mandateId", self.mandateId), - "userId": workflow.get("userId", self.userId), + "_mandateId": workflow.get("_mandateId", self._mandateId), + "_userId": workflow.get("_userId", self._userId), "name": workflow.get("name", f"Workflow {workflowId}"), "status": workflow.get("status", "completed"), "startedAt": workflow.get("startedAt", self._getCurrentTimestamp()), @@ -1187,13 +1213,13 @@ class LucyDOMInterface: messageIds = [msg.get("id") for msg in messages] # Update in database self.updateWorkflow(workflowId, {"messageIds": messageIds}) - logger.info(f"Rebuilt messageIds for workflow {workflowId}") + logger.debug(f"Rebuilt messageIds for workflow {workflowId}") # Log document counts for each message for msg in messages: docCount = len(msg.get("documents", [])) if docCount > 0: - logger.info(f"Message {msg.get('id')} has {docCount} documents loaded from database") + logger.debug(f"Message {msg.get('id')} has {docCount} documents loaded from database") # Load logs logs = self.getWorkflowLogs(workflowId) @@ -1218,16 +1244,16 @@ class LucyDOMInterface: try: # Get token from database using current user's mandateId and userId tokens = self.db.getRecordset("msftTokens", recordFilter={ - "mandateId": self.mandateId, - "userId": self.userId + "_mandateId": self._mandateId, + "_userId": self._userId }) if tokens and len(tokens) > 0: token_data = json.loads(tokens[0]["token_data"]) - logger.info(f"Retrieved Microsoft token for user {self.userId}") + logger.debug(f"Retrieved Microsoft token for user {self._userId}") return token_data else: - logger.info(f"No Microsoft token found for user {self.userId}") + logger.debug(f"No Microsoft token found for user {self._userId}") return None except Exception as e: @@ -1239,8 +1265,8 @@ class LucyDOMInterface: try: # Check if token already exists tokens = self.db.getRecordset("msftTokens", recordFilter={ - "mandateId": self.mandateId, - "userId": self.userId + "_mandateId": self._mandateId, + "_userId": self._userId }) if tokens and len(tokens) > 0: @@ -1251,18 +1277,18 @@ class LucyDOMInterface: "updated_at": datetime.now().isoformat() } self.db.recordModify("msftTokens", token_id, updated_data) - logger.info(f"Updated Microsoft token for user {self.userId}") + logger.debug(f"Updated Microsoft token for user {self._userId}") else: - # Create new token + # Create new token with UUID new_token = { - "mandateId": self.mandateId, - "userId": self.userId, + "_mandateId": self._mandateId, + "_userId": self._userId, "token_data": json.dumps(token_data), "created_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat() } self.db.recordCreate("msftTokens", new_token) - logger.info(f"Saved new Microsoft token for user {self.userId}") + logger.debug(f"Saved new Microsoft token for user {self._userId}") return True @@ -1273,20 +1299,23 @@ class LucyDOMInterface: # Singleton factory for LucyDOMInterface instances per context _lucydomInterfaces = {} -def getLucydomInterface(mandateId: int = 0, userId: int = 0) -> LucyDOMInterface: +def getLucydomInterface(_mandateId: str = None, _userId: str = None) -> LucyDOMInterface: """ Returns a LucyDOMInterface instance for the specified context. - Reuses existing instances. + Ensures AI service is initialized and preserves it across instances. """ - contextKey = f"{mandateId}_{userId}" + # For initialization, use empty strings instead of None + contextKey = f"{_mandateId or ''}_{_userId or ''}" + + # Ensure AI service is initialized + if _aiService is None: + initializeAIService() + + # Create new instance if needed if contextKey not in _lucydomInterfaces: - # Create new interface instance - interface = LucyDOMInterface(mandateId, userId) - # Initialize AI service - aiService = ChatService() - interface.aiService = aiService - _lucydomInterfaces[contextKey] = interface + _lucydomInterfaces[contextKey] = LucyDOMInterface(_mandateId or '', _userId or '') + return _lucydomInterfaces[contextKey] -# Initialize an instance -getLucydomInterface() \ No newline at end of file +# Initialize default instance with empty strings +getLucydomInterface('', '') \ No newline at end of file diff --git a/modules/interfaces/lucydomModel BACKUP.py b/modules/interfaces/lucydomModel BACKUP.py new file mode 100644 index 00000000..2b731c13 --- /dev/null +++ b/modules/interfaces/lucydomModel BACKUP.py @@ -0,0 +1,265 @@ +""" +LucyDOM model classes for the workflow and document system. +""" + +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from datetime import datetime + + +class Label(BaseModel): + """Label for an attribute or a class with support for multiple languages""" + default: str + translations: Dict[str, str] = {} + + def getLabel(self, language: str = None): + """Returns the label in the specified language, or the default value if not available""" + if language and language in self.translations: + return self.translations[language] + return self.default + + +class Prompt(BaseModel): + """Data model for a prompt""" + id: int = Field(description="Unique ID of the prompt") + mandateId: int = Field(description="ID of the associated mandate") + userId: int = Field(description="ID of the creator") + content: str = Field(description="Content of the prompt") + name: str = Field(description="Display name of the prompt") + + label: Label = Field( + default=Label(default="Prompt", translations={"en": "Prompt", "fr": "Invite"}), + description="Label for the class" + ) + + # Labels for attributes + fieldLabels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "content": Label(default="Content", translations={"en": "Content", "fr": "Contenu"}), + "name": Label(default="Name", translations={"en": "Label", "fr": "Nom"}) + } + + +class FileItem(BaseModel): + """Data model for a file""" + id: int = Field(description="Unique ID of the data object") + mandateId: int = Field(description="ID of the associated mandate") + userId: int = Field(description="ID of the creator") + name: str = Field(description="Name of the data object") + mimeType: str = Field(description="Type of the data object MIME type") + size: Optional[int] = Field(None, description="Size of the data object in bytes") + fileHash: str = Field(description="Hash code for deduplication") + creationDate: Optional[str] = Field(None, description="Upload date") + workflowId: Optional[str] = Field(None, description="ID of the associated workflow, if any") + + label: Label = Field( + default=Label(default="Data Object", translations={"en": "Data Object", "fr": "Objet de données"}), + description="Label for the class" + ) + + # Labels for attributes + fieldLabels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), + "mimeType": Label(default="Type", translations={"en": "Type", "fr": "Type"}), + "size": Label(default="Size", translations={"en": "Size", "fr": "Taille"}), + "fileHash": Label(default="File Hash", translations={"en": "Hash", "fr": "Hash"}), + "creationDate": Label(default="Upload date", translations={"en": "Upload date", "fr": "Date de téléchargement"}), + "workflowId": Label(default="Workflow ID", translations={"en": "Workflow ID", "fr": "ID du workflow"}) + } + +class FileData(BaseModel): + """Data model for file content""" + id: int = Field(description="Unique ID of the data object") + data: str = Field(description="content of the file, text or base64 encoded based on base64Encoded flag") + base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") + + +class MsftToken(BaseModel): + """Data model for Microsoft authentication tokens""" + id: int = Field(description="Unique ID of the token") + mandateId: int = Field(description="ID of the associated mandate") + userId: int = Field(description="ID of the user") + token_data: str = Field(description="JSON string containing the token data") + created_at: str = Field(description="Timestamp when the token was created") + updated_at: str = Field(description="Timestamp when the token was last updated") + + label: Label = Field( + default=Label(default="Microsoft Token", translations={"en": "Microsoft Token", "fr": "Jeton Microsoft"}), + description="Label for the class" + ) + + # Labels for attributes + fieldLabels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "token_data": Label(default="Token Data", translations={"en": "Token Data", "fr": "Données du jeton"}), + "created_at": Label(default="Created At", translations={"en": "Created At", "fr": "Créé le"}), + "updated_at": Label(default="Updated At", translations={"en": "Updated At", "fr": "Mis à jour le"}) + } + + +# Workflow model classes + +class DocumentContent(BaseModel): + """Content of a document in the workflow""" + sequenceNr: int = Field(1, description="Sequence number of the content in the source document") + name: str = Field(description="Designation") + ext: str = Field(description="Content extension for export: txt, csv, json, jpg, png") + mimeType: str = Field(description="MIME type") + summary: str = Field(description="Summary of the file content") + data: str = Field(description="Actual content, text or base64 encoded based on base64Encoded flag") + base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata about the content, such as isText flag, format information, encoding, etc.") + +class Document(BaseModel): + """Document in the workflow - References a file directly in the database""" + id: str = Field(description="Unique ID of the document") + name: str = Field(description="Name of the data object") + ext: str = Field(description="Extension of the data object") + fileId: int = Field(description="ID of the referenced file in the database") + mimeType: str = Field(description="MIME type") + data: str = Field(description="Content of the data as text or base64 encoded based on base64Encoded flag") + base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") + contents: List[DocumentContent] = Field(description="Document contents") + +class DataStats(BaseModel): + """Statistics for performance and data usage""" + processingTime: Optional[float] = Field(None, description="Processing time in seconds") + tokenCount: Optional[int] = Field(None, description="Token count (for AI models)") + bytesSent: Optional[int] = Field(None, description="Bytes sent") + bytesReceived: Optional[int] = Field(None, description="Bytes received") + +class WorkflowMessage(BaseModel): + """Message object in the workflow""" + id: str = Field(description="Unique ID of the message") + workflowId: str = Field(description="Reference to the parent workflow") + parentMessageId: Optional[str] = Field(None, description="Reference to the replied message") + startedAt: str = Field(description="Timestamp for message creation") + finishedAt: Optional[str] = Field(None, description="Timestamp for message completion") + sequenceNo: int = Field(description="Sequence number for sorting") + + status: str = Field(description="Status of the message ('first', 'step', 'last')") + role: str = Field(description="Role of the sender ('system', 'user', 'assistant')") + + dataStats: Optional[DataStats] = Field(None, description="Statistics") + documents: Optional[List[Document]] = Field(None, description="Documents in this message (references to files in the database)") + content: Optional[str] = Field(None, description="Text content of the message") + agentName: Optional[str] = Field(None, description="Name of the agent used") + +class WorkflowLog(BaseModel): + """Log entry for a workflow""" + id: str = Field(description="Unique ID of the log entry") + workflowId: str = Field(description="ID of the associated workflow") + message: str = Field(description="Log message content") + type: str = Field(description="Type of log ('info', 'warning', 'error')") + timestamp: str = Field(description="Timestamp of the log entry") + agentName: str = Field(description="Name of the agent that created the log") + status: str = Field(description="Status of the workflow at log time") + progress: Optional[int] = Field(None, description="Progress value (0-100)") + mandateId: Optional[int] = Field(None, description="ID of the mandate") + userId: Optional[int] = Field(None, description="ID of the user") + +class Workflow(BaseModel): + """Workflow object for multi-agent system""" + id: str = Field(description="Unique ID of the workflow") + name: Optional[str] = Field(None, description="Name of the workflow") + mandateId: int = Field(description="ID of the mandate") + userId: int = Field(description="ID of the user") + status: str = Field(description="Status of the workflow ('running', 'completed', 'failed', 'stopped')") + startedAt: str = Field(description="Start timestamp") + lastActivity: str = Field(description="Timestamp of the last activity") + dataStats: Optional[Dict[str, Any]] = Field(None, description="Total statistics") + currentRound: int = Field(default=1, description="Current round/iteration of the workflow") + messageIds: List[str] = Field(default=[], description="List of message IDs in this workflow") + + messages: List[WorkflowMessage] = Field(default=[], description="Message history (in-memory representation)") + logs: List[WorkflowLog] = Field(default=[], description="Log entries (in-memory representation)") + + +# Agent and Workflow Task Models + +class AgentResult(BaseModel): + """Result structure returned by agent processing""" + feedback: str = Field(description="Text response explaining what the agent did") + documents: List[Document] = Field(default=[], description="List of document objects created by the agent") + + label: Label = Field( + default=Label(default="Agent Result", translations={"en": "Agent Result", "fr": "Résultat d'agent"}), + description="Label for the class" + ) + +class AgentInfo(BaseModel): + """Information about an agent's capabilities""" + name: str = Field(description="Name of the agent") + description: str = Field(description="Description of the agent's functionality") + capabilities: List[str] = Field(default=[], description="List of agent capabilities") + + label: Label = Field( + default=Label(default="Agent Information", translations={"en": "Agent Information", "fr": "Information d'agent"}), + description="Label for the class" + ) + + +class InputDocument(BaseModel): + """Input document specification for a task""" + label: str = Field(description="Document label in the format 'filename.ext'") + fileId: Optional[int] = Field(None, description="ID of the existing document if referring to one") + contentPart: str = Field(default="", description="Content part to focus on, empty string for all contents") + prompt: str = Field(description="AI prompt to describe what data to extract from the file") + +class OutputDocument(BaseModel): + """Output document specification for a task""" + label: str = Field(description="Document label in the format 'filename.ext'") + prompt: str = Field(description="AI prompt to describe the content of the file") + +class TaskItem(BaseModel): + """Individual task in the workplan""" + agent: str = Field(description="Name of an available agent") + prompt: str = Field(description="Specific instructions to the agent, that he knows what to do with which documents and which output to provide") + outputDocuments: List[OutputDocument] = Field(default=[], description="List of required output documents") + inputDocuments: List[InputDocument] = Field(default=[], description="List of input documents to process") + + label: Label = Field( + default=Label(default="Task Item", translations={"en": "Task Item", "fr": "Élément de tâche"}), + description="Label for the class" + ) + +class TaskPlan(BaseModel): + """Work plan created by project manager""" + objFinalDocuments: List[str] = Field(default=[], description="List of required result documents") + objWorkplan: List[TaskItem] = Field(default=[], description="Plan for executing agents") + objUserResponse: str = Field(description="Response to the user explaining the plan") + userLanguage: str = Field(default="en", description="Language code of the user's request") + + label: Label = Field( + default=Label(default="Task Plan", translations={"en": "Task Plan", "fr": "Plan de tâches"}), + description="Label for the class" + ) + +class WorkflowStatus(BaseModel): + """Workflow status messages""" + init: str = Field(default="Workflow initialized") + running: str = Field(default="Running workflow") + waiting: str = Field(default="Waiting for input") + completed: str = Field(default="Workflow completed successfully") + stopped: str = Field(default="Workflow stopped by user") + failed: str = Field(default="Error in workflow") + + label: Label = Field( + default=Label(default="Workflow Status", translations={"en": "Workflow Status", "fr": "État du workflow"}), + description="Label for the class" + ) + + +# Request models for the API + +class UserInputRequest(BaseModel): + """Request for user input to a running workflow""" + prompt: str = Field(description="Message from the user") + listFileId: List[int] = Field(default=[], description="List of FileItem IDs") \ No newline at end of file diff --git a/modules/interfaces/lucydomModel.py b/modules/interfaces/lucydomModel.py index c7bb6d37..8c82d7bd 100644 --- a/modules/interfaces/lucydomModel.py +++ b/modules/interfaces/lucydomModel.py @@ -4,8 +4,12 @@ LucyDOM model classes for the workflow and document system. from pydantic import BaseModel, Field from typing import List, Dict, Any, Optional +from datetime import datetime +import uuid +# CORE MODELS + class Label(BaseModel): """Label for an attribute or a class with support for multiple languages""" default: str @@ -20,9 +24,7 @@ class Label(BaseModel): class Prompt(BaseModel): """Data model for a prompt""" - id: int = Field(description="Unique ID of the prompt") - mandateId: int = Field(description="ID of the associated mandate") - userId: int = Field(description="ID of the creator") + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the prompt") content: str = Field(description="Content of the prompt") name: str = Field(description="Display name of the prompt") @@ -34,23 +36,18 @@ class Prompt(BaseModel): # Labels for attributes fieldLabels: Dict[str, Label] = { "id": Label(default="ID", translations={}), - "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), - "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), "content": Label(default="Content", translations={"en": "Content", "fr": "Contenu"}), - "name": Label(default="Name", translations={"en": "Label", "fr": "Nom"}), + "name": Label(default="Name", translations={"en": "Label", "fr": "Nom"}) } class FileItem(BaseModel): """Data model for a file""" - id: int = Field(description="Unique ID of the data object") - mandateId: int = Field(description="ID of the associated mandate") - userId: int = Field(description="ID of the creator") - name: str = Field(description="Name of the data object") - mimeType: str = Field(description="Type of the data object MIME type") - size: Optional[int] = Field(None, description="Size of the data object in bytes") + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the data object") + mimeType: str = Field(description="Type of the file MIME type") + fileName: str = Field(description="Name of the file") + fileSize: int = Field(description="Size of the file in bytes") fileHash: str = Field(description="Hash code for deduplication") - creationDate: Optional[str] = Field(None, description="Upload date") workflowId: Optional[str] = Field(None, description="ID of the associated workflow, if any") label: Label = Field( @@ -61,99 +58,88 @@ class FileItem(BaseModel): # Labels for attributes fieldLabels: Dict[str, Label] = { "id": Label(default="ID", translations={}), - "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), - "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), - "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), "mimeType": Label(default="Type", translations={"en": "Type", "fr": "Type"}), - "size": Label(default="Size", translations={"en": "Size", "fr": "Taille"}), + "fileName": Label(default="Filename", translations={"en": "fileName", "fr": "Nom de fichier"}), + "fileSize": Label(default="Size", translations={"en": "Size", "fr": "Taille"}), "fileHash": Label(default="File Hash", translations={"en": "Hash", "fr": "Hash"}), - "creationDate": Label(default="Upload date", translations={"en": "Upload date", "fr": "Date de téléchargement"}), "workflowId": Label(default="Workflow ID", translations={"en": "Workflow ID", "fr": "ID du workflow"}) } + class FileData(BaseModel): """Data model for file content""" - id: int = Field(description="Unique ID of the data object") + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the data object") data: str = Field(description="content of the file, text or base64 encoded based on base64Encoded flag") base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") + workflowId: Optional[str] = Field(None, description="ID of the associated workflow, if any") + + +class UserInputRequest(BaseModel): + """Request for user input to a running workflow""" + prompt: str = Field(description="Message from the user") + listFileId: List[str] = Field(default=[], description="List of FileItem IDs") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata for the request") class MsftToken(BaseModel): """Data model for Microsoft authentication tokens""" - id: int = Field(description="Unique ID of the token") - mandateId: int = Field(description="ID of the associated mandate") - userId: int = Field(description="ID of the user") - token_data: str = Field(description="JSON string containing the token data") - created_at: str = Field(description="Timestamp when the token was created") - updated_at: str = Field(description="Timestamp when the token was last updated") - - label: Label = Field( - default=Label(default="Microsoft Token", translations={"en": "Microsoft Token", "fr": "Jeton Microsoft"}), - description="Label for the class" - ) - - # Labels for attributes - fieldLabels: Dict[str, Label] = { - "id": Label(default="ID", translations={}), - "mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), - "userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), - "token_data": Label(default="Token Data", translations={"en": "Token Data", "fr": "Données du jeton"}), - "created_at": Label(default="Created At", translations={"en": "Created At", "fr": "Créé le"}), - "updated_at": Label(default="Updated At", translations={"en": "Updated At", "fr": "Mis à jour le"}) - } + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the token") + tokenData: str = Field(description="JSON string containing the token data") + expiresAt: datetime = Field(description="Expiration date and time") + refreshToken: Optional[str] = Field(None, description="Refresh token if available") + scope: str = Field(description="Token scope") -# Workflow model classes +# WORKFLOW MODELS -class DocumentContent(BaseModel): - """Content of a document in the workflow""" +class ChatContent(BaseModel): + """Content of a document in the chat""" sequenceNr: int = Field(1, description="Sequence number of the content in the source document") name: str = Field(description="Designation") - ext: str = Field(description="Content extension for export: txt, csv, json, jpg, png") mimeType: str = Field(description="MIME type") - summary: str = Field(description="Summary of the file content") - data: str = Field(description="Actual content, text or base64 encoded based on base64Encoded flag") - base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") - metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata about the content, such as isText flag, format information, encoding, etc.") + data: str = Field(description="Actual content") + metadata: Dict[str, Any] = Field(default_factory=dict, description="Metadata about the content") -class Document(BaseModel): - """Document in the workflow - References a file directly in the database""" - id: str = Field(description="Unique ID of the document") - name: str = Field(description="Name of the data object") - ext: str = Field(description="Extension of the data object") - fileId: int = Field(description="ID of the referenced file in the database") + +class ChatDocument(BaseModel): + """Document in the chat workflow""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the document") + fileId: str = Field(description="ID of the referenced file in the database") + fileName: str = Field(description="Name of the file") + fileSize: int = Field(description="Size of the file in bytes") mimeType: str = Field(description="MIME type") - data: str = Field(description="Content of the data as text or base64 encoded based on base64Encoded flag") - base64Encoded: bool = Field(description="Flag indicating whether the data is base64 encoded") - contents: List[DocumentContent] = Field(description="Document contents") + contents: List[ChatContent] = Field(default=[], description="Document contents") -class DataStats(BaseModel): + +class ChatStat(BaseModel): """Statistics for performance and data usage""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the stats") processingTime: Optional[float] = Field(None, description="Processing time in seconds") tokenCount: Optional[int] = Field(None, description="Token count (for AI models)") bytesSent: Optional[int] = Field(None, description="Bytes sent") bytesReceived: Optional[int] = Field(None, description="Bytes received") -class WorkflowMessage(BaseModel): - """Message object in the workflow""" - id: str = Field(description="Unique ID of the message") + +class ChatMessage(BaseModel): + """Message object in the chat workflow""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the message") workflowId: str = Field(description="Reference to the parent workflow") parentMessageId: Optional[str] = Field(None, description="Reference to the replied message") - startedAt: str = Field(description="Timestamp for message creation") - finishedAt: Optional[str] = Field(None, description="Timestamp for message completion") - sequenceNo: int = Field(description="Sequence number for sorting") - - status: str = Field(description="Status of the message ('first', 'step', 'last')") - role: str = Field(description="Role of the sender ('system', 'user', 'assistant')") - - dataStats: Optional[DataStats] = Field(None, description="Statistics") - documents: Optional[List[Document]] = Field(None, description="Documents in this message (references to files in the database)") - content: Optional[str] = Field(None, description="Text content of the message") agentName: Optional[str] = Field(None, description="Name of the agent used") + documents: Optional[List[ChatDocument]] = Field(None, description="Documents in this message") + message: Optional[str] = Field(None, description="Text content of the message") + role: str = Field(description="Role of the sender ('system', 'user', 'assistant')") + status: str = Field(description="Status of the message ('first', 'step', 'last')") -class WorkflowLog(BaseModel): - """Log entry for a workflow""" - id: str = Field(description="Unique ID of the log entry") + sequenceNr: int = Field(description="Sequence number for sorting") + startedAt: datetime = Field(description="Timestamp for message creation") + finishedAt: Optional[datetime] = Field(None, description="Timestamp for message completion") + stats: Optional[ChatStat] = Field(None, description="Statistics") + + +class ChatLog(BaseModel): + """Log entry for a chat workflow""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the log entry") workflowId: str = Field(description="ID of the associated workflow") message: str = Field(description="Log message content") type: str = Field(description="Type of log ('info', 'warning', 'error')") @@ -161,105 +147,50 @@ class WorkflowLog(BaseModel): agentName: str = Field(description="Name of the agent that created the log") status: str = Field(description="Status of the workflow at log time") progress: Optional[int] = Field(None, description="Progress value (0-100)") - mandateId: Optional[int] = Field(None, description="ID of the mandate") - userId: Optional[int] = Field(None, description="ID of the user") -class Workflow(BaseModel): - """Workflow object for multi-agent system""" - id: str = Field(description="Unique ID of the workflow") - name: Optional[str] = Field(None, description="Name of the workflow") - mandateId: int = Field(description="ID of the mandate") - userId: int = Field(description="ID of the user") - status: str = Field(description="Status of the workflow ('running', 'completed', 'failed', 'stopped')") - startedAt: str = Field(description="Start timestamp") + +class ChatWorkflow(BaseModel): + """Chat workflow object for multi-agent system""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the chat workflow") + status: str = Field(description="Status of the chat workflow") + name: Optional[str] = Field(None, description="Name of the chat workflow") + currentRound: int = Field(default=1, description="Current round/iteration") lastActivity: str = Field(description="Timestamp of the last activity") - dataStats: Optional[Dict[str, Any]] = Field(None, description="Total statistics") - currentRound: int = Field(default=1, description="Current round/iteration of the workflow") - messageIds: List[str] = Field(default=[], description="List of message IDs in this workflow") - - messages: List[WorkflowMessage] = Field(default=[], description="Message history (in-memory representation)") - logs: List[WorkflowLog] = Field(default=[], description="Log entries (in-memory representation)") + startedAt: str = Field(description="Start timestamp") + logs: List[ChatLog] = Field(default=[], description="Log entries") + messages: List[ChatMessage] = Field(default=[], description="Message history") + stats: Optional[ChatStat] = Field(None, description="Statistics") -# Agent and Workflow Task Models +# AGENT AND TASK MODELS -class AgentResult(BaseModel): - """Result structure returned by agent processing""" - feedback: str = Field(description="Text response explaining what the agent did") - documents: List[Document] = Field(default=[], description="List of document objects created by the agent") - - label: Label = Field( - default=Label(default="Agent Result", translations={"en": "Agent Result", "fr": "Résultat d'agent"}), - description="Label for the class" - ) - -class AgentInfo(BaseModel): - """Information about an agent's capabilities""" +class Agent(BaseModel): + """Data model for an agent""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the agent") name: str = Field(description="Name of the agent") description: str = Field(description="Description of the agent's functionality") capabilities: List[str] = Field(default=[], description="List of agent capabilities") - label: Label = Field( - default=Label(default="Agent Information", translations={"en": "Agent Information", "fr": "Information d'agent"}), - description="Label for the class" - ) +class AgentResponse(BaseModel): + """Response structure returned by agent processing""" + response: str = Field(description="Text response from the agent") + documents: List[ChatDocument] = Field(default=[], description="List of document objects created by the agent") -class InputDocument(BaseModel): - """Input document specification for a task""" - label: str = Field(description="Document label in the format 'filename.ext'") - fileId: Optional[int] = Field(None, description="ID of the existing document if referring to one") - contentPart: str = Field(default="", description="Content part to focus on, empty string for all contents") - prompt: str = Field(description="AI prompt to describe what data to extract from the file") - -class OutputDocument(BaseModel): - """Output document specification for a task""" - label: str = Field(description="Document label in the format 'filename.ext'") - prompt: str = Field(description="AI prompt to describe the content of the file") - class TaskItem(BaseModel): """Individual task in the workplan""" - agent: str = Field(description="Name of an available agent") - prompt: str = Field(description="Specific instructions to the agent, that he knows what to do with which documents and which output to provide") - outputDocuments: List[OutputDocument] = Field(default=[], description="List of required output documents") - inputDocuments: List[InputDocument] = Field(default=[], description="List of input documents to process") + sequenceNr: int = Field(description="Sequence number of the task") + agentName: str = Field(description="Name of an available agent") + prompt: str = Field(description="Specific instructions to the agent") + userLanguage: str = Field(description="Language code of the user's request") + filesInput: List[str] = Field(default=[], description="List of input files in format 'fileName[;documentId]'") + filesOutput: List[str] = Field(default=[], description="List of output files in format 'fileName'") - label: Label = Field( - default=Label(default="Task Item", translations={"en": "Task Item", "fr": "Élément de tâche"}), - description="Label for the class" - ) class TaskPlan(BaseModel): """Work plan created by project manager""" - objFinalDocuments: List[str] = Field(default=[], description="List of required result documents") - objWorkplan: List[TaskItem] = Field(default=[], description="Plan for executing agents") - objUserResponse: str = Field(description="Response to the user explaining the plan") + fileList: List[str] = Field(default=[], description="List of required result documents in format 'fileName'") + taskItems: List[TaskItem] = Field(default=[], description="Plan for executing agents") + userResponse: str = Field(description="Response to the user explaining the plan") userLanguage: str = Field(default="en", description="Language code of the user's request") - - label: Label = Field( - default=Label(default="Task Plan", translations={"en": "Task Plan", "fr": "Plan de tâches"}), - description="Label for the class" - ) - -class WorkflowStatus(BaseModel): - """Workflow status messages""" - init: str = Field(default="Workflow initialized") - running: str = Field(default="Running workflow") - waiting: str = Field(default="Waiting for input") - completed: str = Field(default="Workflow completed successfully") - stopped: str = Field(default="Workflow stopped by user") - failed: str = Field(default="Error in workflow") - - label: Label = Field( - default=Label(default="Workflow Status", translations={"en": "Workflow Status", "fr": "État du workflow"}), - description="Label for the class" - ) - - -# Request models for the API - -class UserInputRequest(BaseModel): - """Request for user input to a running workflow""" - prompt: str = Field(description="Message from the user") - listFileId: List[int] = Field(default=[], description="List of FileItem IDs") \ No newline at end of file diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index b9cd752f..45f2092d 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -1,34 +1,39 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Response from typing import List, Dict, Any from fastapi import status +import inspect +import importlib +import os +from pydantic import BaseModel from modules.security.auth import getCurrentActiveUser, getUserContext # Import the attribute definition and helper functions from modules.shared.defAttributes import AttributeDefinition, getModelAttributes -# Import the model modules (without specific classes) -import modules.interfaces.gatewayModel as gatewayModel -import modules.interfaces.lucydomModel as lucydomModel - -modelClasses = { - # Gateway model classes - "mandate": gatewayModel.Mandate, - "user": gatewayModel.User, +def getModelClasses() -> Dict[str, Any]: + """Dynamically get all model classes from all model modules""" + modelClasses = {} - # LucyDOM model classes - admin - "file": lucydomModel.FileItem, - "prompt": lucydomModel.Prompt, - - # LucyDOM model classes - chat - "documentContent": lucydomModel.DocumentContent, - "document": lucydomModel.Document, - "dataStats": lucydomModel.DataStats, - "userInputRequest": lucydomModel.UserInputRequest, - "workflow": lucydomModel.Workflow, - "workflowMessage": lucydomModel.WorkflowMessage, - "workflowLog": lucydomModel.WorkflowLog, -} + # Get the interfaces directory path + # Since we're in modules/routes/, we need to go up one level to modules/ then into interfaces/ + interfaces_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'interfaces') + + # Find all model files + for filename in os.listdir(interfaces_dir): + if filename.endswith('Model.py'): + # Convert filename to module name (e.g., gatewayModel.py -> gatewayModel) + module_name = filename[:-3] + + # Import the module dynamically + module = importlib.import_module(f'modules.interfaces.{module_name}') + + # Get all classes from the module + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, BaseModel) and obj != BaseModel: + modelClasses[name.lower()] = obj + + return modelClasses # Create a router for the attribute endpoints router = APIRouter( @@ -52,6 +57,9 @@ async def getEntityAttributes( # Determine preferred language of the user userLanguage = currentUser.get("language", "de") + # Get model classes dynamically + modelClasses = getModelClasses() + # Check if entity type is known if entityType not in modelClasses: raise HTTPException( diff --git a/modules/routes/routeFiles.py b/modules/routes/routeFiles.py index 524016ba..4e64a038 100644 --- a/modules/routes/routeFiles.py +++ b/modules/routes/routeFiles.py @@ -26,23 +26,25 @@ def getModelAttributes(modelClass): # Model attributes for FileItem fileAttributes = getModelAttributes(FileItem) -@dataclass class AppContext: - """Context object for all required connections and user information""" - mandateId: int - userId: int - interfaceData: Any # LucyDOM Interface + def __init__(self, mandateId: int, userId: int): + self._mandateId = mandateId + self._userId = userId + self.interfaceData = getLucydomInterface(mandateId, userId) async def getContext(currentUser: Dict[str, Any]) -> AppContext: - """Creates a central context object with all required connections""" - mandateId, userId = await getUserContext(currentUser) - interfaceData = getLucydomInterface(mandateId, userId) + """ + Creates a central context object with all required interfaces - return AppContext( - mandateId=mandateId, - userId=userId, - interfaceData=interfaceData - ) + Args: + currentUser: Current user from authentication + + Returns: + AppContext object with all required connections + """ + _mandateId, _userId = await getUserContext(currentUser) + + return AppContext(_mandateId, _userId) # Create router for file endpoints router = APIRouter( diff --git a/modules/routes/routeGeneral.py b/modules/routes/routeGeneral.py index 0aa60055..ad1562ae 100644 --- a/modules/routes/routeGeneral.py +++ b/modules/routes/routeGeneral.py @@ -6,6 +6,7 @@ from typing import Dict, Any from datetime import timedelta import pathlib import os +import logging from modules.shared.configuration import APP_CONFIG from modules.security.auth import ( @@ -27,6 +28,8 @@ os.makedirs(staticFolder, exist_ok=True) # Mount static files router.mount("/static", StaticFiles(directory=str(staticFolder), html=True), name="static") +logger = logging.getLogger(__name__) + @router.get("/favicon.ico") async def favicon(): return FileResponse(str(staticFolder / "favicon.ico"), media_type="image/x-icon") @@ -56,31 +59,140 @@ async def get_environment(): @router.post("/api/token", response_model=gatewayModel.Token, tags=["General"]) async def loginForAccessToken(formData: OAuth2PasswordRequestForm = Depends()): - # Initialize Gateway interface without context - gateway = getGatewayInterface() - - # Authenticate user - user = gateway.authenticateUser(formData.username, formData.password) + # Get root mandate and admin user IDs + adminGateway = getGatewayInterface() + rootMandateId = adminGateway.getInitialId("mandates") + adminUserId = adminGateway.getInitialId("users") - if not user: + if not rootMandateId or not adminUserId: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", - headers={"WWW-Authenticate": "Bearer"}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="System is not properly initialized with root mandate and admin user" ) - # Create token with tenant ID - accessTokenExpires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - accessToken = createAccessToken( - data={ - "sub": user["username"], - "mandateId": user["mandateId"] - }, - expiresDelta=accessTokenExpires - ) - - return {"accessToken": accessToken, "tokenType": "bearer"} + # Create a new gateway interface instance with admin context + adminGateway = getGatewayInterface(rootMandateId, adminUserId) + + try: + # Authenticate user + user = adminGateway.authenticateUser(formData.username, formData.password) + + # Create token with mandate ID and user ID + accessTokenExpires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + accessToken = createAccessToken( + data={ + "sub": user["username"], + "_mandateId": str(user["_mandateId"]), # Ensure string + "_userId": str(user["id"]) # Ensure string + }, + expiresDelta=accessTokenExpires + ) + + logger.info(f"User {user['username']} successfully logged in with context: _mandateId={user['_mandateId']}, _userId={user['id']}") + return {"accessToken": accessToken, "tokenType": "bearer"} + except ValueError as e: + # Handle authentication errors + error_msg = str(e) + logger.warning(f"Authentication failed for user {formData.username}: {error_msg}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error_msg, + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception as e: + # Handle other errors + error_msg = f"Login failed: {str(e)}" + logger.error(f"Unexpected error during login for user {formData.username}: {error_msg}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=error_msg + ) @router.get("/api/user/me", response_model=Dict[str, Any], tags=["General"]) async def readUserMe(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): - return currentUser \ No newline at end of file + return currentUser + +@router.post("/api/users/register", response_model=Dict[str, Any], tags=["General"]) +async def registerUser(userData: Dict[str, Any]): + """Register a new user.""" + try: + logger.info("Received registration request") + logger.info(f"Raw userData type: {type(userData)}") + logger.info(f"Raw userData content: {userData}") + + # Get root mandate and admin user IDs + adminGateway = getGatewayInterface() + rootMandateId = adminGateway.getInitialId("mandates") + adminUserId = adminGateway.getInitialId("users") + + if not rootMandateId or not adminUserId: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="System is not properly initialized with root mandate and admin user" + ) + + # Create a new gateway interface instance with admin context + adminGateway = getGatewayInterface(rootMandateId, adminUserId) + + # Check required fields + if not userData or not isinstance(userData, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid user data format" + ) + + if not userData.get("username") or not userData.get("password"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username and password are required" + ) + + # Create user data with mandate ID + userData = { + "username": userData["username"], + "password": userData["password"], + "email": userData.get("email"), + "fullName": userData.get("fullName"), + "language": userData.get("language", "de"), + "_mandateId": rootMandateId, + "disabled": False, + "privilege": "user" + } + + # Create the user + createdUser = adminGateway.createUser(**userData) + + if not createdUser: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user" + ) + + # Clear the users table from cache to ensure fresh data + if hasattr(adminGateway.db, '_tablesCache') and "users" in adminGateway.db._tablesCache: + del adminGateway.db._tablesCache["users"] + + # Return the created user (without password) + if "hashedPassword" in createdUser: + del createdUser["hashedPassword"] + return createdUser + except ValueError as e: + logger.error(f"ValueError during registration: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except PermissionError as e: + logger.error(f"PermissionError during registration: {str(e)}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error during user registration: {str(e)}") + logger.error(f"Error type: {type(e)}") + logger.error(f"Error details: {e.__dict__ if hasattr(e, '__dict__') else 'No details available'}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to register user" + ) \ No newline at end of file diff --git a/modules/routes/routeMandates.py b/modules/routes/routeMandates.py index 981508f9..ce7f784c 100644 --- a/modules/routes/routeMandates.py +++ b/modules/routes/routeMandates.py @@ -19,23 +19,15 @@ def getModelAttributes(modelClass): # Model attributes for Mandate mandateAttributes = getModelAttributes(Mandate) -@dataclass class AppContext: - """Context object for all required connections and user information""" - mandateId: int - userId: int - interfaceData: Any # Gateway Interface + def __init__(self, mandateId: int, userId: int): + self._mandateId = mandateId + self._userId = userId + self.interfaceData = getGatewayInterface(mandateId, userId) async def getContext(currentUser: Dict[str, Any]) -> AppContext: - """Creates a central context object with all required connections""" mandateId, userId = await getUserContext(currentUser) - interfaceData = getGatewayInterface(mandateId, userId) - - return AppContext( - mandateId=mandateId, - userId=userId, - interfaceData=interfaceData - ) + return AppContext(mandateId, userId) # Create router for mandate endpoints router = APIRouter( @@ -89,19 +81,19 @@ async def createMandate( return newMandate -@router.get("/{mandateId}", response_model=Dict[str, Any]) +@router.get("/{_mandateId}", response_model=Dict[str, Any]) async def getMandate( - mandateId: int, + _mandateId: str = Path(..., description="ID of the mandate"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): - """Get a specific mandate""" + """Get a mandate by ID.""" context = await getContext(currentUser) # Permission check # Admin can only see their own mandate, SysAdmin can see all isAdmin = currentUser.get("privilege") == "admin" isSysadmin = currentUser.get("privilege") == "sysadmin" - isOwnMandate = context.mandateId == mandateId + isOwnMandate = context._mandateId == _mandateId if (isAdmin and not isOwnMandate) and not isSysadmin: raise HTTPException( @@ -110,36 +102,36 @@ async def getMandate( ) # Get mandate - mandate = context.interfaceData.getMandate(mandateId) + mandate = context.interfaceData.getMandate(_mandateId) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mandate with ID {mandateId} not found" + detail=f"Mandate with ID {_mandateId} not found" ) return mandate -@router.put("/{mandateId}", response_model=Dict[str, Any]) +@router.put("/{_mandateId}", response_model=Dict[str, Any]) async def updateMandate( - mandateId: int = Path(..., description="ID of the mandate to update"), - mandateData: Dict[str, Any] = Body(..., description="Updated mandate data"), + _mandateId: str = Path(..., description="ID of the mandate to update"), + mandateData: Dict[str, Any] = Body(...), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): - """Update an existing mandate""" + """Update a mandate.""" context = await getContext(currentUser) - # Mandate exists? - mandate = context.interfaceData.getMandate(mandateId) + # Get mandate + mandate = context.interfaceData.getMandate(_mandateId) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mandate with ID {mandateId} not found" + detail=f"Mandate with ID {_mandateId} not found" ) # Permission check isAdmin = currentUser.get("privilege") == "admin" isSysadmin = currentUser.get("privilege") == "sysadmin" - isOwnMandate = context.mandateId == mandateId + isOwnMandate = context._mandateId == _mandateId if (isAdmin and not isOwnMandate) and not isSysadmin: raise HTTPException( @@ -154,33 +146,29 @@ async def updateMandate( updateData[attr] = mandateData[attr] # Update mandate - updatedMandate = context.interfaceData.updateMandate( - mandateId=mandateId, - mandateData=updateData - ) - + updatedMandate = context.interfaceData.updateMandate(_mandateId, mandateData) return updatedMandate -@router.delete("/{mandateId}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{_mandateId}", status_code=status.HTTP_204_NO_CONTENT) async def deleteMandate( - mandateId: int = Path(..., description="ID of the mandate to delete"), + _mandateId: str = Path(..., description="ID of the mandate to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): - """Delete a mandate, including all associated users and referenced objects""" + """Delete a mandate.""" context = await getContext(currentUser) - # Mandate exists? - mandate = context.interfaceData.getMandate(mandateId) + # Get mandate + mandate = context.interfaceData.getMandate(_mandateId) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mandate with ID {mandateId} not found" + detail=f"Mandate with ID {_mandateId} not found" ) # Permission check isAdmin = currentUser.get("privilege") == "admin" isSysadmin = currentUser.get("privilege") == "sysadmin" - isOwnMandate = context.mandateId == mandateId + isOwnMandate = context._mandateId == _mandateId if (isAdmin and not isOwnMandate) and not isSysadmin: raise HTTPException( @@ -189,11 +177,11 @@ async def deleteMandate( ) # Delete mandate - success = context.interfaceData.deleteMandate(mandateId) + success = context.interfaceData.deleteMandate(_mandateId) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error deleting mandate with ID {mandateId}" + detail=f"Error deleting mandate with ID {_mandateId}" ) return None \ No newline at end of file diff --git a/modules/routes/routeMsft.py b/modules/routes/routeMsft.py index 77fb9f9a..8401dee8 100644 --- a/modules/routes/routeMsft.py +++ b/modules/routes/routeMsft.py @@ -48,15 +48,15 @@ async def save_token_to_file(token_data, currentUser: Dict[str, Any]): """Save token data to database using LucyDOMInterface""" try: # Get current user context - mandateId, userId = await getUserContext(currentUser) - if not mandateId or not userId: + _mandateId, _userId = await getUserContext(currentUser) + if not _mandateId or not _userId: logger.error("No user context available for token storage") return False # Get LucyDOM interface for current user mydom = getLucydomInterface( - mandateId=mandateId, - userId=userId + _mandateId=_mandateId, + _userId=_userId ) if not mydom: logger.error("No LucyDOM interface available for token storage") @@ -79,15 +79,15 @@ async def load_token_from_file(currentUser: Dict[str, Any]): """Load token data from database using LucyDOMInterface""" try: # Get current user context - mandateId, userId = await getUserContext(currentUser) - if not mandateId or not userId: + _mandateId, _userId = await getUserContext(currentUser) + if not _mandateId or not _userId: logger.error("No user context available for token retrieval") return None # Get LucyDOM interface for current user mydom = getLucydomInterface( - mandateId=mandateId, - userId=userId + _mandateId=_mandateId, + _userId=_userId ) if not mydom: logger.error("No LucyDOM interface available for token retrieval") @@ -350,8 +350,8 @@ async def auth_status(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser """Check Microsoft authentication status""" try: # Get current user context - mandateId, userId = await getUserContext(currentUser) - if not mandateId or not userId: + _mandateId, _userId = await getUserContext(currentUser) + if not _mandateId or not _userId: logger.info("No user context found") return JSONResponse({ "authenticated": False, @@ -362,7 +362,7 @@ async def auth_status(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser token_data = await load_token_from_file(currentUser) if not token_data: - logger.info(f"No token data found for user {userId}") + logger.info(f"No token data found for user {_userId}") return JSONResponse({ "authenticated": False, "message": "Not authenticated with Microsoft" @@ -372,7 +372,7 @@ async def auth_status(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser if not verify_token(token_data["access_token"]): logger.info("Token invalid, attempting refresh") # Try to refresh the token - if not await refresh_token(userId, currentUser): + if not await refresh_token(_userId, currentUser): logger.info("Token refresh failed") return JSONResponse({ "authenticated": False, @@ -408,16 +408,16 @@ async def logout(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Logout from Microsoft""" try: # Get current user context - mandateId, userId = await getUserContext(currentUser) - if not mandateId or not userId: + _mandateId, _userId = await getUserContext(currentUser) + if not _mandateId or not _userId: return JSONResponse({ "message": "Not authenticated with Microsoft" }) # Get LucyDOM interface for current user mydom = getLucydomInterface( - mandateId=mandateId, - userId=userId + _mandateId=_mandateId, + _userId=_userId ) if not mydom: return JSONResponse({ @@ -426,13 +426,13 @@ async def logout(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): # Remove token from database tokens = mydom.db.getRecordset("msftTokens", recordFilter={ - "mandateId": mandateId, - "userId": userId + "_mandateId": _mandateId, + "_userId": _userId }) if tokens and len(tokens) > 0: mydom.db.recordDelete("msftTokens", tokens[0]["id"]) - logger.info(f"Removed Microsoft token for user {userId}") + logger.info(f"Removed Microsoft token for user {_userId}") return JSONResponse({ "message": "Successfully logged out from Microsoft" @@ -491,7 +491,7 @@ async def get_backend_token(request: Request): status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid authorization header" ) - + # Extract the MSAL token msal_token = auth_header.split(' ')[1] @@ -502,7 +502,7 @@ async def get_backend_token(request: Request): status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MSAL token" ) - + # Get the user from the database using the email gateway = getGatewayInterface() user = gateway.getUserByUsername(user_info["email"]) @@ -512,17 +512,17 @@ async def get_backend_token(request: Request): status_code=status.HTTP_401_UNAUTHORIZED, detail="User not registered in the system" ) - + # Create backend token access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = createAccessToken( data={ "sub": user["username"], - "mandateId": user["mandateId"] + "_mandateId": user["_mandateId"] }, expiresDelta=access_token_expires ) - + return { "accessToken": access_token, "tokenType": "bearer", @@ -530,7 +530,7 @@ async def get_backend_token(request: Request): "username": user["username"], "email": user["email"], "fullName": user.get("fullName", ""), - "mandateId": user["mandateId"] + "_mandateId": user["_mandateId"] } } diff --git a/modules/routes/routePrompts.py b/modules/routes/routePrompts.py index a42ca865..90f9c41b 100644 --- a/modules/routes/routePrompts.py +++ b/modules/routes/routePrompts.py @@ -21,23 +21,15 @@ def getModelAttributes(modelClass): # Model attributes for Prompt promptAttributes = getModelAttributes(Prompt) -@dataclass class AppContext: - """Context object for all required connections and user information""" - mandateId: int - userId: int - interfaceData: Any # LucyDOM Interface + def __init__(self, mandateId: str, userId: str): + self._mandateId = mandateId + self._userId = userId + self.interfaceData = getLucydomInterface(mandateId, userId) async def getContext(currentUser: Dict[str, Any]) -> AppContext: - """Creates a central context object with all required connections""" mandateId, userId = await getUserContext(currentUser) - interfaceData = getLucydomInterface(mandateId, userId) - - return AppContext( - mandateId=mandateId, - userId=userId, - interfaceData=interfaceData - ) + return AppContext(mandateId, userId) # Create router for prompt endpoints router = APIRouter( @@ -82,7 +74,7 @@ async def createPrompt( @router.get("/{promptId}", response_model=Dict[str, Any]) async def getPrompt( - promptId: int, + promptId: str = Path(..., description="ID of the prompt"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Get a specific prompt""" @@ -100,7 +92,7 @@ async def getPrompt( @router.put("/{promptId}", response_model=Dict[str, Any]) async def updatePrompt( - promptId: int, + promptId: str = Path(..., description="ID of the prompt to update"), promptData: Dict[str, Any] = Body(...), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): @@ -136,7 +128,7 @@ async def updatePrompt( @router.delete("/{promptId}", response_model=Dict[str, Any]) async def deletePrompt( - promptId: int, + promptId: str = Path(..., description="ID of the prompt to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Delete a prompt""" diff --git a/modules/routes/routeUsers.py b/modules/routes/routeUsers.py index af53b75c..468fe867 100644 --- a/modules/routes/routeUsers.py +++ b/modules/routes/routeUsers.py @@ -30,18 +30,18 @@ userAttributes = getModelAttributes(User) @dataclass class AppContext: """Context object for all required connections and user information""" - mandateId: int - userId: int + _mandateId: int + _userId: int interfaceData: Any # Gateway Interface async def getContext(currentUser: Dict[str, Any]) -> AppContext: """Creates a central context object with all required connections""" - mandateId, userId = await getUserContext(currentUser) - interfaceData = getGatewayInterface(mandateId, userId) + _mandateId, _userId = await getUserContext(currentUser) + interfaceData = getGatewayInterface(_mandateId, _userId) return AppContext( - mandateId=mandateId, - userId=userId, + _mandateId=_mandateId, + _userId=_userId, interfaceData=interfaceData ) @@ -66,7 +66,7 @@ async def getUsers(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): # Admin sees only users of own mandate, SysAdmin sees all if currentUser.get("privilege") == "admin": - return context.interfaceData.getUsersByMandate(context.mandateId) + return context.interfaceData.getUsersByMandate(context._mandateId) else: # sysadmin return context.interfaceData.getAllUsers() @@ -80,8 +80,15 @@ async def registerUser(request: Request): logger.info(f"Registration request data: {data}") # Get root mandate and admin user IDs - rootMandateId = 1 # Root mandate is always ID 1 - adminUserId = 1 # Admin user is always ID 1 + adminGateway = getGatewayInterface() + rootMandateId = adminGateway.getInitialId("mandates") + adminUserId = adminGateway.getInitialId("users") + + if not rootMandateId or not adminUserId: + raise HTTPException( + status_code=500, + detail="System is not properly initialized with root mandate and admin user" + ) # Create a new gateway interface instance with admin context adminGateway = getGatewayInterface(rootMandateId, adminUserId) @@ -98,7 +105,7 @@ async def registerUser(request: Request): "email": data.get("email"), "fullName": data.get("fullName"), "language": data.get("language", "de"), - "mandateId": rootMandateId, + "_mandateId": rootMandateId, "disabled": False, "privilege": "user" } @@ -125,16 +132,26 @@ async def registerUser(request: Request): logger.info("User verification successful") # Test authentication - authResult = adminGateway.authenticateUser(userData["username"], userData["password"]) - if not authResult: - logger.error("Authentication test failed after user creation") + try: + authResult = adminGateway.authenticateUser(userData["username"], userData["password"]) + if not authResult: + logger.error("Authentication test failed after user creation") + # Try to delete the user + try: + adminGateway.deleteUser(createdUser["id"]) + logger.info("Successfully deleted user after authentication test failure") + except Exception as e: + logger.error(f"Failed to delete user after authentication test failure: {str(e)}") + raise HTTPException(status_code=500, detail="Authentication test failed") + except ValueError as e: + logger.error(f"Authentication test failed: {str(e)}") # Try to delete the user try: - # adminGateway.deleteUser(createdUser["id"]) - logger.info("Successfully NOT deleted user after authentication test failure") + adminGateway.deleteUser(createdUser["id"]) + logger.info("Successfully deleted user after authentication test failure") except Exception as e: logger.error(f"Failed to delete user after authentication test failure: {str(e)}") - raise HTTPException(status_code=500, detail="Authentication test failed") + raise HTTPException(status_code=500, detail=f"Authentication test failed: {str(e)}") logger.info("Authentication test successful") @@ -194,7 +211,7 @@ async def registerUserWithMsal(userData: dict = Body(...)): email=userData.get("email"), fullName=userData.get("fullName"), language=userData.get("language", "de"), - mandateId=rootMandateId, + _mandateId=rootMandateId, disabled=False, privilege="user" ) @@ -214,7 +231,7 @@ async def registerUserWithMsal(userData: dict = Body(...)): @router.get("/{userId}", response_model=Dict[str, Any]) async def getUser( - userId: int, + userId: str = Path(..., description="ID of the user"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Get a specific user""" @@ -230,10 +247,10 @@ async def getUser( # Permission check # User can only view themselves, Admin only users of their own mandate, SysAdmin all - if userId == context.userId: + if userId == str(context._userId): # User can view themselves pass - elif currentUser.get("privilege") == "admin" and userToGet.get("mandateId") == context.mandateId: + elif currentUser.get("privilege") == "admin" and userToGet.get("_mandateId") == context._mandateId: # Admin can view users of their own mandate pass elif currentUser.get("privilege") == "sysadmin": @@ -249,7 +266,7 @@ async def getUser( @router.put("/{userId}", response_model=Dict[str, Any]) async def updateUser( - userId: int = Path(..., description="ID of the user to update"), + userId: str = Path(..., description="ID of the user to update"), userData: Dict[str, Any] = Body(..., description="Updated user data"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): @@ -265,14 +282,14 @@ async def updateUser( ) # Permission check - isSelfUpdate = userId == context.userId + isSelfUpdate = userId == str(context._userId) isAdmin = currentUser.get("privilege") == "admin" isSysadmin = currentUser.get("privilege") == "sysadmin" - sameMandate = userToUpdate.get("mandateId") == context.mandateId + sameMandate = userToUpdate.get("_mandateId") == context._mandateId # Filter allowed fields based on permission level allowedFields = {"username", "email", "fullName", "language"} - sensitiveFields = {"mandateId", "disabled", "privilege"} + sensitiveFields = {"_mandateId", "disabled", "privilege"} # Check if sensitive fields should be changed sensitiveUpdate = any(field in userData for field in sensitiveFields) @@ -312,7 +329,7 @@ async def updateUser( @router.delete("/{userId}", status_code=status.HTTP_204_NO_CONTENT) async def deleteUser( - userId: int = Path(..., description="ID of the user to delete"), + userId: str = Path(..., description="ID of the user to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Delete a user""" @@ -327,10 +344,10 @@ async def deleteUser( ) # Permission check - isSelfDelete = userId == context.userId + isSelfDelete = userId == str(context._userId) isAdmin = currentUser.get("privilege") == "admin" isSysadmin = currentUser.get("privilege") == "sysadmin" - sameMandate = userToDelete.get("mandateId") == context.mandateId + sameMandate = userToDelete.get("_mandateId") == context._mandateId if isSelfDelete: # User can delete themselves diff --git a/modules/routes/routeWorkflows.py b/modules/routes/routeWorkflows.py index 3165b156..04902b10 100644 --- a/modules/routes/routeWorkflows.py +++ b/modules/routes/routeWorkflows.py @@ -29,34 +29,15 @@ router = APIRouter( responses={404: {"description": "Not found"}} ) -@dataclass class AppContext: - """Context object for all required connections and user information""" - mandateId: int - userId: int - interfaceData: Any # LucyDOM Interface - interfaceChat: Any # Workflow Manager + def __init__(self, mandateId: int, userId: int): + self._mandateId = mandateId + self._userId = userId + self.interfaceData = getLucydomInterface(mandateId, userId) async def getContext(currentUser: Dict[str, Any]) -> AppContext: - """ - Creates a central context object with all required interfaces - - Args: - currentUser: Current user from authentication - - Returns: - AppContext object with all required connections - """ mandateId, userId = await getUserContext(currentUser) - interfaceData = getLucydomInterface(mandateId, userId) - interfaceChat = getWorkflowManager(mandateId, userId) - - return AppContext( - mandateId=mandateId, - userId=userId, - interfaceData=interfaceData, - interfaceChat=interfaceChat - ) + return AppContext(mandateId, userId) # State 1: Workflow Initialization endpoint @router.post("/start", response_model=Dict[str, Any]) @@ -87,7 +68,7 @@ async def startWorkflow( } # Start or continue workflow using the workflow manager - workflow = await context.interfaceChat.workflowStart(userInputDict, workflowId) + workflow = await getWorkflowManager(context._mandateId, context._userId).workflowStart(userInputDict, workflowId) logger.info("User Input received. Answer:",workflow) return { @@ -130,14 +111,14 @@ async def stopWorkflow( detail=f"Workflow with ID {workflowId} not found" ) - if workflow.get("userId") != context.userId: + if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to stop this workflow" ) # Stop the workflow - stoppedWorkflow = await context.interfaceChat.workflowStop(workflowId) + stoppedWorkflow = getWorkflowManager(context._mandateId, context._userId).workflowStop(workflowId) return { "id": workflowId, @@ -183,7 +164,7 @@ async def deleteWorkflow( ) # Check if user has permission to delete - if workflow.get("userId") != context.userId: + if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to delete this workflow" @@ -230,7 +211,7 @@ async def listWorkflows( try: # Retrieve workflows for the user - workflows = context.interfaceData.getWorkflowsByUser(context.userId) + workflows = context.interfaceData.getWorkflowsByUser(context._userId) return workflows except Exception as e: logger.error(f"Error listing workflows: {str(e)}", exc_info=True) @@ -431,7 +412,7 @@ async def deleteWorkflowMessage( detail=f"Workflow with ID {workflowId} not found" ) - if workflow.get("userId") != context.userId: + if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to modify this workflow" @@ -471,7 +452,7 @@ async def deleteWorkflowMessage( async def deleteFileFromMessage( workflowId: str = Path(..., description="ID of the workflow"), messageId: str = Path(..., description="ID of the message"), - fileId: int = Path(..., description="ID of the file to delete"), + fileId: str = Path(..., description="ID of the file to delete"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ @@ -498,7 +479,7 @@ async def deleteFileFromMessage( detail=f"Workflow with ID {workflowId} not found" ) - if workflow.get("userId") != context.userId: + if workflow.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to modify this workflow" @@ -533,7 +514,7 @@ async def deleteFileFromMessage( @router.get("/files/{fileId}/preview", response_model=Dict[str, Any]) async def previewFile( - fileId: int = Path(..., description="ID of the file to preview"), + fileId: str = Path(..., description="ID of the file to preview"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ @@ -558,7 +539,7 @@ async def previewFile( ) # Check if file belongs to user or their mandate - if file.get("mandateId") != context.mandateId and file.get("userId") != context.userId: + if file.get("_mandateId") != context._mandateId and file.get("_userId") != context._userId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to access this file" @@ -636,7 +617,7 @@ async def previewFile( @router.get("/files/{fileId}/download") async def downloadFile( - fileId: int = Path(..., description="ID of the file to download"), + fileId: str = Path(..., description="ID of the file to download"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """ @@ -676,4 +657,122 @@ async def downloadFile( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error downloading file: {str(e)}" - ) \ No newline at end of file + ) + +@router.get("/workflows", response_model=List[Dict[str, Any]]) +async def getWorkflows(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): + context = await getContext(currentUser) + + # Get all workflows for the mandate + workflows = context.interfaceData.getWorkflowsByMandate(context._mandateId) + + return workflows + +@router.post("/workflows", response_model=Dict[str, Any]) +async def createWorkflow( + workflow: Dict[str, Any], + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + context = await getContext(currentUser) + + # Create workflow + newWorkflow = context.interfaceData.createWorkflow(workflow) + + return newWorkflow + +@router.get("/workflows/{workflowId}", response_model=Dict[str, Any]) +async def getWorkflow( + workflowId: str = Path(..., description="ID of the workflow"), + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + context = await getContext(currentUser) + + # Get workflow + workflow = context.interfaceData.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Check if user has access to this workflow + if workflow.get("_userId") != context._userId: + raise HTTPException(status_code=403, detail="Not authorized to access this workflow") + + return workflow + +@router.put("/workflows/{workflowId}", response_model=Dict[str, Any]) +async def updateWorkflow( + workflow: Dict[str, Any], + workflowId: str = Path(..., description="ID of the workflow to update"), + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + context = await getContext(currentUser) + + # Get workflow + existingWorkflow = context.interfaceData.getWorkflow(workflowId) + if not existingWorkflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Check if user has access to this workflow + if existingWorkflow.get("_userId") != context._userId: + raise HTTPException(status_code=403, detail="Not authorized to update this workflow") + + # Update workflow + updatedWorkflow = context.interfaceData.updateWorkflow(workflowId, workflow) + + return updatedWorkflow + +@router.delete("/workflows/{workflowId}") +async def deleteWorkflow( + workflowId: str = Path(..., description="ID of the workflow to delete"), + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + context = await getContext(currentUser) + + # Get workflow + workflow = context.interfaceData.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Check if user has access to this workflow + if workflow.get("_userId") != context._userId: + raise HTTPException(status_code=403, detail="Not authorized to delete this workflow") + + # Delete workflow + success = context.interfaceData.deleteWorkflow(workflowId) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete workflow") + + return {"status": "success"} + +@router.post("/workflows/{workflowId}/files/{fileId}") +async def addFileToWorkflow( + workflowId: str = Path(..., description="ID of the workflow"), + fileId: str = Path(..., description="ID of the file to add"), + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """Add a file to a workflow.""" + context = await getContext(currentUser) + + # Get workflow + workflow = context.interfaceData.getWorkflow(workflowId) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Check access + if workflow.get("_userId") != context._userId: + raise HTTPException(status_code=403, detail="No access to this workflow") + + # Get file + file = context.interfaceData.getFile(fileId) + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # Check file access + if file.get("_mandateId") != context._mandateId and file.get("_userId") != context._userId: + raise HTTPException(status_code=403, detail="No access to this file") + + # Add file to workflow + success = context.interfaceData.addFileToWorkflow(workflowId, fileId) + if not success: + raise HTTPException(status_code=500, detail="Failed to add file to workflow") + + return {"status": "success"} \ No newline at end of file diff --git a/modules/security/auth.py b/modules/security/auth.py index 7506ea12..9d15e4c2 100644 --- a/modules/security/auth.py +++ b/modules/security/auth.py @@ -75,15 +75,20 @@ async def getCurrentUser(token: str = Depends(oauth2Scheme)) -> Dict[str, Any]: if username is None: raise credentialsException - # Extract mandate ID from token (if present) - mandateId: int = payload.get("mandateId", 1) # Default: Root mandate + # Extract mandate ID and user ID from token + _mandateId: str = payload.get("_mandateId") + _userId: str = payload.get("_userId") + + if not _mandateId or not _userId: + logger.error(f"Missing context in token: _mandateId={_mandateId}, _userId={_userId}") + raise credentialsException except JWTError: logger.warning("Invalid JWT Token") raise credentialsException - # Initialize Gateway Interface without context - gateway = getGatewayInterface() + # Initialize Gateway Interface with context + gateway = getGatewayInterface(_mandateId, _userId) # Retrieve user from database user = gateway.getUserByUsername(username) @@ -96,6 +101,11 @@ async def getCurrentUser(token: str = Depends(oauth2Scheme)) -> Dict[str, Any]: logger.warning(f"User {username} is disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled") + # Ensure the user has the correct context + if str(user.get("_mandateId")) != str(_mandateId) or str(user.get("id")) != str(_userId): + logger.error(f"User context mismatch: token(_mandateId={_mandateId}, _userId={_userId}) vs user(_mandateId={user.get('_mandateId')}, id={user.get('id')})") + raise credentialsException + return user async def getCurrentActiveUser(currentUser: Dict[str, Any] = Depends(getCurrentUser)) -> Dict[str, Any]: @@ -116,43 +126,48 @@ async def getCurrentActiveUser(currentUser: Dict[str, Any] = Depends(getCurrentU return currentUser -async def getUserContext(currentUser: Dict[str, Any]) -> Tuple[int, int]: +async def getUserContext(currentUser: Dict[str, Any]) -> Tuple[str, str]: """ Extracts the mandate ID and user ID from the current user. - Enhanced with better logging. Args: currentUser: The current user Returns: - Tuple of (mandateId, userId) + Tuple of (_mandateId, _userId) as strings + + Raises: + HTTPException: If mandate or user ID is missing """ - # Default values - defaultMandateId = 0 - defaultUserId = 0 + # Extract _mandateId + _mandateId = currentUser.get("_mandateId") + if not _mandateId: + logger.error("No _mandateId found in currentUser") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing mandate context" + ) - # Extract mandateId - mandateId = currentUser.get("mandateId", None) - if mandateId is None: - logger.warning(f"No mandateId found in currentUser, using default: {defaultMandateId}") - mandateId = defaultMandateId - else: - try: - mandateId = int(mandateId) - except (ValueError, TypeError): - logger.error(f"Invalid mandateId value: {mandateId}, using default: {defaultMandateId}") - mandateId = defaultMandateId + # Extract _userId + _userId = currentUser.get("id") # Note: using 'id' instead of '_userId' + if not _userId: + logger.error("No _userId found in currentUser") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing user context" + ) - # Extract userId - userId = currentUser.get("id", None) - if userId is None: - logger.warning(f"No userId found in currentUser, using default: {defaultUserId}") - userId = defaultUserId - else: - try: - userId = int(userId) - except (ValueError, TypeError): - logger.error(f"Invalid userId value: {userId}, using default: {defaultUserId}") - userId = defaultUserId + return str(_mandateId), str(_userId) + +def getInitialContext() -> tuple[str, str]: + """ + Returns the initial mandate and user IDs from the gateway. + This is used by other interfaces to get their context. + Returns: + tuple[str, str]: (_mandateId, _userId) or (None, None) if not available + """ + gateway = getGatewayInterface() + mandateId = gateway.getInitialId("mandates") + userId = gateway.getInitialId("users") return mandateId, userId \ No newline at end of file diff --git a/modules/shared/defAttributes.py b/modules/shared/defAttributes.py index 731ecfd9..39fc21a1 100644 --- a/modules/shared/defAttributes.py +++ b/modules/shared/defAttributes.py @@ -111,8 +111,8 @@ def getModelAttributes(modelClass, userLanguage="de"): placeholder=placeholder, defaultValue=defaultValue, options=options, - editable=fieldName not in ["id", "mandateId", "userId", "createdAt", "uploadDate"], - visible=fieldName not in ["hashedPassword", "mandateId", "userId"], + editable=fieldName not in ["id", "_mandateId", "_userId", "uploadDate", "_createdAt", "_modifiedAt"], + visible=fieldName not in ["hashedPassword", "_mandateId", "_userId"], order=i, validation=validation, helpText=description or "" # Set empty string as default value if no description found diff --git a/modules/workflow/agentBase.py b/modules/workflow/agentBase.py index 05223388..bcfac6e0 100644 --- a/modules/workflow/agentBase.py +++ b/modules/workflow/agentBase.py @@ -25,11 +25,18 @@ class AgentBase: self.label = "Base Agent" self.description = "Base agent functionality" self.capabilities = [] + self.workflowManager = None self.mydom = None - self.workflowManager = None # Will be set by workflow manager - def setDependencies(self, mydom=None): - """Set external dependencies for the agent.""" + def setWorkflowManager(self, workflowManager): + """Set the workflow manager reference.""" + self.workflowManager = workflowManager + # Also set mydom reference from workflow manager + if workflowManager and hasattr(workflowManager, 'mydom'): + self.mydom = workflowManager.mydom + + def setMydom(self, mydom): + """Set the LucyDOM interface reference.""" self.mydom = mydom def getAgentInfo(self) -> Dict[str, Any]: diff --git a/modules/workflow/agentRegistry.py b/modules/workflow/agentRegistry.py index 2665df25..4558c7b2 100644 --- a/modules/workflow/agentRegistry.py +++ b/modules/workflow/agentRegistry.py @@ -30,9 +30,16 @@ class AgentRegistry: raise RuntimeError("Singleton instance already exists - use getInstance()") self.agents = {} - self.mydom = None self._loadAgents() + def initialize(self, mydom=None, workflowManager=None): + """Initialize or update the registry with workflow manager and LucyDOM references.""" + for agent in self.agents.values(): + if workflowManager and hasattr(agent, 'setWorkflowManager'): + agent.setWorkflowManager(workflowManager) + elif mydom and hasattr(agent, 'setMydom'): + agent.setMydom(mydom) + def _loadAgents(self): """Load all available agents from modules.""" logger.info("Loading agent modules...") @@ -88,22 +95,6 @@ class AgentRegistry: except Exception as e: logger.error(f"Error loading agent from module {moduleName}: {e}") - def setMydom(self, mydom): - """Set the AI service for all agents.""" - self.mydom = mydom - self.updateAgentDependencies() - - def setWorkflowManager(self, workflowManager): - """Set the workflow manager reference for all agents.""" - for agent in self.agents.values(): - agent.workflowManager = workflowManager - - def updateAgentDependencies(self): - """Update dependencies for all registered agents.""" - for agentId, agent in self.agents.items(): - if hasattr(agent, 'setDependencies'): - agent.setDependencies(mydom=self.mydom) - def registerAgent(self, agent): """ Register an agent in the registry. @@ -112,9 +103,6 @@ class AgentRegistry: agent: The agent to register """ agentId = getattr(agent, 'name', "unknown_agent") - # Initialize agent with dependencies - if hasattr(agent, 'setDependencies'): - agent.setDependencies(mydom=self.mydom) self.agents[agentId] = agent logger.debug(f"Agent '{agent.name}' registered") @@ -127,11 +115,7 @@ class AgentRegistry: Agent instance or None if not found """ if agentIdentifier in self.agents: - agent = self.agents[agentIdentifier] - # Ensure the agent has the AI service - if self.mydom: - agent.mydom = self.mydom - return agent + return self.agents[agentIdentifier] logger.error(f"Agent with identifier '{agentIdentifier}' not found") return None diff --git a/modules/workflow/workflowAgentsRegistry.py b/modules/workflow/workflowAgentsRegistry.py deleted file mode 100644 index f742a1ac..00000000 --- a/modules/workflow/workflowAgentsRegistry.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Agent Registry Module. -Provides a central registry system for all available agents. -Optimized for the standardized task processing pattern. -""" - -from .agentBase import AgentBase -from .agentRegistry import AgentRegistry, getAgentRegistry - -__all__ = ['AgentBase', 'AgentRegistry', 'getAgentRegistry'] \ No newline at end of file diff --git a/modules/workflow/workflowManager.py b/modules/workflow/workflowManager.py index 5082b563..a0c2b6b0 100644 --- a/modules/workflow/workflowManager.py +++ b/modules/workflow/workflowManager.py @@ -41,26 +41,49 @@ class WorkflowStoppedException(Exception): pass class WorkflowManager: - """ - Manages the processing of chat requests, agent execution, and - the integration of results into the workflow, following a state machine approach. - """ - - def __init__(self, mandateId: int, userId: int): - """ - Initializes the WorkflowManager with mandate and user context. - - Args: - mandateId: ID of the current mandate - userId: ID of the current user - """ - self.mandateId = mandateId - self.userId = userId - self.mydom = domInterface(mandateId, userId) + """Manages the execution of workflows and their associated agents.""" + + def __init__(self, _mandateId: str, _userId: str): + """Initialize the workflow manager with mandate and user context.""" + self._mandateId = _mandateId + self._userId = _userId + self.mydom = domInterface(_mandateId, _userId) self.agentRegistry = getAgentRegistry() - self.agentRegistry.setMydom(self.mydom) - self.agentRegistry.setWorkflowManager(self) # Set self as workflow manager for all agents - + self.agentRegistry.initialize(mydom=self.mydom, workflowManager=self) + + def workflowStart(self, workflowId: str, workflowData: dict) -> dict: + """Start a new workflow with the given ID and data.""" + try: + # Update the LucyDOM interface with current user context + self.mydom._mandateId = self._mandateId + self.mydom._userId = self._userId + + # Initialize workflow state + workflowState = { + 'workflowId': workflowId, + 'status': 'running', + 'startTime': datetime.now().isoformat(), + 'currentStep': 0, + 'steps': [], + 'data': workflowData + } + + # Get workflow definition + workflowDef = self._getWorkflowDefinition(workflowId) + if not workflowDef: + raise ValueError(f"Workflow definition not found for ID: {workflowId}") + + # Initialize steps + workflowState['steps'] = self._initializeSteps(workflowDef) + + # Start workflow execution + self._executeWorkflow(workflowState) + + return workflowState + + except Exception as e: + logger.error(f"Error starting workflow {workflowId}: {str(e)}") + raise ### Workflow State Machine Implementation @@ -280,8 +303,8 @@ class WorkflowManager: newWorkflowId = str(uuid.uuid4()) if workflowId is None else workflowId workflow = { "id": newWorkflowId, - "mandateId": self.mandateId, - "userId": self.userId, + "_mandateId": self._mandateId, + "_userId": self._userId, "name": f"Workflow {newWorkflowId[:8]}", "startedAt": currentTime, "messages": [], # Empty list - will be filled with references @@ -301,8 +324,8 @@ class WorkflowManager: # Save to database - only the workflow metadata workflowDb = { "id": workflow["id"], - "mandateId": workflow["mandateId"], - "userId": workflow["userId"], + "_mandateId": workflow["_mandateId"], + "_userId": workflow["_userId"], "name": workflow["name"], "startedAt": workflow["startedAt"], "status": workflow["status"], @@ -593,7 +616,12 @@ JSON_OUTPUT = {{ try: # Process the task using the agent's standardized interface logger.debug("TASK: "+self.parseJson2text(agentTask)) - logger.debug(f"Agent '{agentName}' AI service available: {agent.mydom is not None}") + + # Ensure AI service is available + if not self.mydom.aiService: + logger.error("AI service not available in LucyDOM interface") + self.logAdd(workflow, "Error: AI service not available", level="error") + return [] # Calculate bytes sent before processing bytesSent = len(json.dumps(agentTask).encode('utf-8')) @@ -885,8 +913,8 @@ filesDelivered = {self.parseJson2text(matchingDocuments)} continue # Check if file belongs to the current mandate - if file.get("mandateId") != self.mandateId: - logger.warning(f"File {fileId} does not belong to mandate {self.mandateId}") + if file.get("_mandateId") != self._mandateId: + logger.warning(f"File {fileId} does not belong to mandate {self._mandateId}") continue # Load file content @@ -1511,49 +1539,48 @@ filesDelivered = {self.parseJson2text(matchingDocuments)} "userLanguage": "en" } + def _createWorkflowData(self, workflow: Dict[str, Any]) -> Dict[str, Any]: + """Creates a workflow data structure.""" + return { + "_mandateId": self._mandateId, + "_userId": self._userId, + "name": workflow.get("name", "New Workflow"), + "status": workflow.get("status", "running"), + "startedAt": workflow.get("startedAt", self._getCurrentTimestamp()), + "lastActivity": workflow.get("lastActivity", self._getCurrentTimestamp()), + "dataStats": workflow.get("dataStats", {}) + } + + def _checkFileAccess(self, fileId: int) -> bool: + """Checks if the current user has access to a file.""" + file = self.mydom.getFile(fileId) + if not file: + return False + + if file.get("_mandateId") != self._mandateId: + logger.warning(f"File {fileId} does not belong to mandate {self._mandateId}") + return False + + return True + # Singleton factory for the WorkflowManager _workflowManagers = {} _workflowManagerLastAccess = {} # Track last access time for cleanup -def getWorkflowManager(mandateId: int = 0, userId: int = 0) -> WorkflowManager: - """ - Returns a WorkflowManager for the specified context. - Reuses existing instances but implements cleanup for inactive instances. - - Args: - mandateId: ID of the mandate - userId: ID of the user - - Returns: - WorkflowManager instance - """ - contextKey = f"{mandateId}_{userId}" - current_time = datetime.now() - - # Update last access time - _workflowManagerLastAccess[contextKey] = current_time - - # Cleanup old instances (older than 1 hour) - cleanup_threshold = current_time - timedelta(hours=1) - for key in list(_workflowManagers.keys()): - if _workflowManagerLastAccess.get(key, current_time) < cleanup_threshold: - del _workflowManagers[key] - del _workflowManagerLastAccess[key] - - if contextKey not in _workflowManagers: - _workflowManagers[contextKey] = WorkflowManager(mandateId, userId) - return _workflowManagers[contextKey] +def getWorkflowManager(_mandateId: str = '', _userId: str = '') -> WorkflowManager: + """Get a workflow manager instance with the specified context.""" + return WorkflowManager(_mandateId=_mandateId, _userId=_userId) -def cleanupWorkflowManager(mandateId: int, userId: int) -> None: +def cleanupWorkflowManager(_mandateId: int, _userId: int) -> None: """ Explicitly cleanup a WorkflowManager instance. Args: - mandateId: ID of the mandate - userId: ID of the user + _mandateId: ID of the mandate + _userId: ID of the user """ - contextKey = f"{mandateId}_{userId}" + contextKey = f"{_mandateId}_{_userId}" if contextKey in _workflowManagers: del _workflowManagers[contextKey] if contextKey in _workflowManagerLastAccess: diff --git a/notes/changelog.txt b/notes/changelog.txt index 056a465e..f225346e 100644 --- a/notes/changelog.txt +++ b/notes/changelog.txt @@ -1,5 +1,12 @@ ....................... TASKS +for all created records +- to add _createdAt (datetime) and _modifiedAt (datetime), initially _createdAt=_modifiedAt + +for all updated records +- to update attribute _modifiedAt + + ! function callAI() to ask with userPrompt,systemPrompt optional), not with json ! in the taskplan to refer files always in context of user/mandate ! userinput to handle with object AgentQuery --> when received in frontend to enhance for full object