From 98b258ae53ca04abf20c32e055e371a3e65b01c8 Mon Sep 17 00:00:00 2001 From: Christopher Gondek Date: Fri, 3 Oct 2025 09:48:32 +0200 Subject: [PATCH] feat: add langgraph first tool; pydantic v2 --- modules/datamodels/datamodelSecurity.py | 101 ++- .../features/chatBot/chatbotTools/__init__.py | 1 + .../chatbotTools/sharedTools/__init__.py | 8 +- .../sharedTools/toolTavilySearch.py | 55 ++ modules/interfaces/interfaceDbAppObjects.py | 700 ++++++++++-------- modules/shared/attributeUtils.py | 278 ++++--- requirements.txt | 7 +- 7 files changed, 718 insertions(+), 432 deletions(-) create mode 100644 modules/features/chatBot/chatbotTools/__init__.py create mode 100644 modules/features/chatBot/chatbotTools/sharedTools/toolTavilySearch.py diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py index ff6a3f6f..fa8b8ed7 100644 --- a/modules/datamodels/datamodelSecurity.py +++ b/modules/datamodels/datamodelSecurity.py @@ -1,7 +1,7 @@ """Security models: Token and AuthEvent.""" from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from modules.shared.attributeUtils import register_model_labels, ModelMixin from modules.shared.timezoneUtils import get_utc_timestamp from .datamodelUam import AuthAuthority @@ -18,21 +18,36 @@ class Token(BaseModel, ModelMixin): id: Optional[str] = None userId: str authority: AuthAuthority - connectionId: Optional[str] = Field(None, description="ID of the connection this token belongs to") + connectionId: Optional[str] = Field( + None, description="ID of the connection this token belongs to" + ) tokenAccess: str tokenType: str = "bearer" - expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)") + expiresAt: float = Field( + description="When the token expires (UTC timestamp in seconds)" + ) tokenRefresh: Optional[str] = None - createdAt: Optional[float] = Field(None, description="When the token was created (UTC timestamp in seconds)") - status: TokenStatus = Field(default=TokenStatus.ACTIVE, description="Token status: active/revoked") - revokedAt: Optional[float] = Field(None, description="When the token was revoked (UTC timestamp in seconds)") - revokedBy: Optional[str] = Field(None, description="User ID who revoked the token (admin/self)") + createdAt: Optional[float] = Field( + None, description="When the token was created (UTC timestamp in seconds)" + ) + status: TokenStatus = Field( + default=TokenStatus.ACTIVE, description="Token status: active/revoked" + ) + revokedAt: Optional[float] = Field( + None, description="When the token was revoked (UTC timestamp in seconds)" + ) + revokedBy: Optional[str] = Field( + None, description="User ID who revoked the token (admin/self)" + ) reason: Optional[str] = Field(None, description="Optional revocation reason") - sessionId: Optional[str] = Field(None, description="Logical session grouping for logout revocation") - mandateId: Optional[str] = Field(None, description="Mandate ID for tenant scoping of the token") + sessionId: Optional[str] = Field( + None, description="Logical session grouping for logout revocation" + ) + mandateId: Optional[str] = Field( + None, description="Mandate ID for tenant scoping of the token" + ) - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) register_model_labels( @@ -59,14 +74,60 @@ register_model_labels( class AuthEvent(BaseModel, ModelMixin): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", frontend_type="text", frontend_readonly=True, frontend_required=False) - userId: str = Field(description="ID of the user this event belongs to", frontend_type="text", frontend_readonly=True, frontend_required=True) - eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", frontend_type="text", frontend_readonly=True, frontend_required=True) - timestamp: float = Field(default_factory=get_utc_timestamp, description="Unix timestamp when the event occurred", frontend_type="datetime", frontend_readonly=True, frontend_required=True) - ipAddress: Optional[str] = Field(default=None, description="IP address from which the event originated", frontend_type="text", frontend_readonly=True, frontend_required=False) - userAgent: Optional[str] = Field(default=None, description="User agent string from the request", frontend_type="text", frontend_readonly=True, frontend_required=False) - success: bool = Field(default=True, description="Whether the authentication event was successful", frontend_type="boolean", frontend_readonly=True, frontend_required=True) - details: Optional[str] = Field(default=None, description="Additional details about the event", frontend_type="text", frontend_readonly=True, frontend_required=False) + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the auth event", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + userId: str = Field( + description="ID of the user this event belongs to", + frontend_type="text", + frontend_readonly=True, + frontend_required=True, + ) + eventType: str = Field( + description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", + frontend_type="text", + frontend_readonly=True, + frontend_required=True, + ) + timestamp: float = Field( + default_factory=get_utc_timestamp, + description="Unix timestamp when the event occurred", + frontend_type="datetime", + frontend_readonly=True, + frontend_required=True, + ) + ipAddress: Optional[str] = Field( + default=None, + description="IP address from which the event originated", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + userAgent: Optional[str] = Field( + default=None, + description="User agent string from the request", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + success: bool = Field( + default=True, + description="Whether the authentication event was successful", + frontend_type="boolean", + frontend_readonly=True, + frontend_required=True, + ) + details: Optional[str] = Field( + default=None, + description="Additional details about the event", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) register_model_labels( @@ -83,5 +144,3 @@ register_model_labels( "details": {"en": "Details", "fr": "Détails"}, }, ) - - diff --git a/modules/features/chatBot/chatbotTools/__init__.py b/modules/features/chatBot/chatbotTools/__init__.py new file mode 100644 index 00000000..2bd4359d --- /dev/null +++ b/modules/features/chatBot/chatbotTools/__init__.py @@ -0,0 +1 @@ +"""Contains all tools available for the chatbot to use.""" diff --git a/modules/features/chatBot/chatbotTools/sharedTools/__init__.py b/modules/features/chatBot/chatbotTools/sharedTools/__init__.py index b0b10bb2..9b0ab5b7 100644 --- a/modules/features/chatBot/chatbotTools/sharedTools/__init__.py +++ b/modules/features/chatBot/chatbotTools/sharedTools/__init__.py @@ -1 +1,7 @@ -"""Tools that are custom to a specific customer go here.""" +"""Shared tools available across all chatbot implementations.""" + +from modules.features.chatBot.chatbotTools.sharedTools.toolTavilySearch import ( + tavily_search, +) + +__all__ = ["tavily_search"] diff --git a/modules/features/chatBot/chatbotTools/sharedTools/toolTavilySearch.py b/modules/features/chatBot/chatbotTools/sharedTools/toolTavilySearch.py new file mode 100644 index 00000000..e3a6c8fc --- /dev/null +++ b/modules/features/chatBot/chatbotTools/sharedTools/toolTavilySearch.py @@ -0,0 +1,55 @@ +"""Tavily Search Tool for LangGraph. + +This tool provides web search capabilities using the Tavily API. +""" + +import logging +from typing import Annotated +from langchain_core.tools import tool +from modules.connectors.connectorAiTavily import ConnectorWeb + +logger = logging.getLogger(__name__) + + +@tool +async def tavily_search( + query: Annotated[str, "The search query to look up on the web"], +) -> str: + """Search the web using Tavily API. + + Use this tool to search for current information, news, or any web content. + The tool returns relevant search results including titles and URLs. + + Args: + query: The search query string + + Returns: + A formatted string containing search results with titles and URLs + """ + try: + # Create connector instance + connector = await ConnectorWeb.create() + + # Perform search with default parameters + results = await connector._search( + query=query, + max_results=5, + search_depth="basic", + include_answer=True, + include_raw_content=False, + ) + + # Format results + if not results: + return f"No results found for query: {query}" + + formatted_results = [f"Search results for '{query}':\n"] + for i, result in enumerate(results, 1): + formatted_results.append(f"{i}. {result.title}") + formatted_results.append(f" URL: {result.url}\n") + + return "\n".join(formatted_results) + + except Exception as e: + logger.error(f"Error in tavily_search tool: {str(e)}") + return f"Error performing search: {str(e)}" diff --git a/modules/interfaces/interfaceDbAppObjects.py b/modules/interfaces/interfaceDbAppObjects.py index 251452f5..36a07484 100644 --- a/modules/interfaces/interfaceDbAppObjects.py +++ b/modules/interfaces/interfaceDbAppObjects.py @@ -18,11 +18,19 @@ from modules.shared.configuration import APP_CONFIG from modules.shared.timezoneUtils import get_utc_now, get_utc_timestamp from modules.interfaces.interfaceDbAppAccess import AppAccess from modules.datamodels.datamodelUam import ( - User, Mandate, UserInDB, UserConnection, - AuthAuthority, UserPrivilege, ConnectionStatus, + User, + Mandate, + UserInDB, + UserConnection, + AuthAuthority, + UserPrivilege, + ConnectionStatus, ) from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus -from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes +from modules.datamodels.datamodelNeutralizer import ( + DataNeutraliserConfig, + DataNeutralizerAttributes, +) logger = logging.getLogger(__name__) @@ -35,12 +43,13 @@ _rootAppObjects = None # Password-Hashing pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") + class AppObjects: """ Interface to the Gateway system. Manages users and mandates. """ - + def __init__(self, currentUser: Optional[User] = None): """Initializes the Gateway Interface.""" # Initialize variables @@ -48,47 +57,49 @@ class AppObjects: self.userId = currentUser.id if currentUser else None self.mandateId = currentUser.mandateId if currentUser else None self.access = None # Will be set when user context is provided - + # Initialize database self._initializeDatabase() - + # Initialize standard records if needed self._initRecords() - + # Set user context if provided if currentUser: self.setUserContext(currentUser) - + def setUserContext(self, currentUser: User): """Sets the user context for the interface.""" if not currentUser: logger.info("Initializing interface without user context") return - + self.currentUser = currentUser # Store User object directly self.userId = currentUser.id self.mandateId = currentUser.mandateId - + if not self.userId or not self.mandateId: raise ValueError("Invalid user context: id and mandateId are required") - + # Add language settings self.userLanguage = currentUser.language # Default user language - + # Initialize access control with user context - self.access = AppAccess(self.currentUser, self.db) # Convert to dict only when needed - + self.access = AppAccess( + self.currentUser, self.db + ) # Convert to dict only when needed + # Update database context self.db.updateContext(self.userId) - + def __del__(self): """Cleanup method to close database connection.""" - if hasattr(self, 'db') and self.db is not None: + if hasattr(self, "db") and self.db is not None: try: self.db.close() except Exception as e: logger.error(f"Error closing database connection: {e}") - + def _initializeDatabase(self): """Initializes the database connection directly.""" try: @@ -98,7 +109,7 @@ class AppObjects: dbUser = APP_CONFIG.get("DB_APP_USER") dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432)) - + # Create database connector directly self.db = DatabaseConnector( dbHost=dbHost, @@ -106,40 +117,36 @@ class AppObjects: dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, - userId=self.userId + userId=self.userId, ) - + # Initialize database system self.db.initDbSystem() - + logger.info(f"Database initialized successfully for user {self.userId}") except Exception as e: logger.error(f"Failed to initialize database: {str(e)}") raise - + def _initRecords(self): """Initialize standard records if they don't exist.""" self._initRootMandate() self._initAdminUser() self._initEventUser() - + def _initRootMandate(self): """Creates the Root mandate if it doesn't exist.""" existingMandateId = self.getInitialId(Mandate) mandates = self.db.getRecordset(Mandate) if existingMandateId is None or not mandates: logger.info("Creating Root mandate") - rootMandate = Mandate( - name="Root", - language="en", - enabled=True - ) + rootMandate = Mandate(name="Root", language="en", enabled=True) createdMandate = self.db.recordCreate(Mandate, rootMandate) logger.info(f"Root mandate created with ID {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.""" existingUserId = self.getInitialId(UserInDB) @@ -155,12 +162,14 @@ class AppObjects: language="en", privilege=UserPrivilege.SYSADMIN, authenticationAuthority="local", # Using lowercase value directly - hashedPassword=self._getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")), - connections=[] + hashedPassword=self._getPasswordHash( + APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET") + ), + connections=[], ) createdUser = self.db.recordCreate(UserInDB, adminUser) logger.info(f"Admin user created with ID {createdUser['id']}") - + # Update user context self.currentUser = createdUser self.userId = createdUser.get("id") @@ -168,7 +177,9 @@ class AppObjects: def _initEventUser(self): """Creates the Event user if it doesn't exist.""" # Check if event user already exists - existingUsers = self.db.getRecordset(UserInDB, recordFilter={"username": "event"}) + existingUsers = self.db.getRecordset( + UserInDB, recordFilter={"username": "event"} + ) if not existingUsers: logger.info("Creating Event user") eventUser = UserInDB( @@ -180,44 +191,48 @@ class AppObjects: language="en", privilege=UserPrivilege.SYSADMIN, authenticationAuthority="local", # Using lowercase value directly - hashedPassword=self._getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")), - connections=[] + hashedPassword=self._getPasswordHash( + APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET") + ), + connections=[], ) createdUser = self.db.recordCreate(UserInDB, eventUser) logger.info(f"Event user created with ID {createdUser['id']}") - def _uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _uam( + self, model_class: type, recordset: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: """ Unified user access management function that filters data based on user privileges and adds access control attributes. - + Args: model_class: Pydantic model class for the table recordset: Recordset to filter based on access rules - + Returns: Filtered recordset with access control attributes """ # First apply access control filteredRecords = self.access.uam(model_class, recordset) - + # Then filter out database-specific fields cleanedRecords = [] for record in filteredRecords: # Create a new dict with only non-database fields - cleanedRecord = {k: v for k, v in record.items() if not k.startswith('_')} + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} cleanedRecords.append(cleanedRecord) - + return cleanedRecords - + def _canModify(self, model_class: type, recordId: Optional[str] = None) -> bool: """ Checks if the current user can modify (create/update/delete) records in a table. - + Args: model_class: Pydantic model class for the table recordId: Optional record ID for specific record check - + Returns: Boolean indicating permission """ @@ -230,22 +245,22 @@ class AppObjects: def _getPasswordHash(self, password: str) -> str: """Creates a hash for a password.""" return pwdContext.hash(password) - + def _verifyPassword(self, plainPassword: str, hashedPassword: str) -> bool: """Checks if the password matches the hash.""" return pwdContext.verify(plainPassword, hashedPassword) - + # User methods - + def getUsersByMandate(self, mandateId: str) -> List[User]: """Returns users for a specific mandate if user has access.""" # Get users for this mandate users = self.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId}) filteredUsers = self._uam(UserInDB, users) - + # Convert to User models return [User.from_dict(user) for user in filteredUsers] - + def getUserByUsername(self, username: str) -> Optional[User]: """Returns a user by username.""" try: @@ -253,19 +268,19 @@ class AppObjects: users = self.db.getRecordset(UserInDB) if not users: return None - + # Find user by username for user_dict in users: if user_dict.get("username") == username: return User.from_dict(user_dict) - + logger.info(f"No user found with username {username}") return None - + except Exception as e: logger.error(f"Error getting user by username: {str(e)}") return None - + def getUser(self, userId: str) -> Optional[User]: """Returns a user by ID if user has access.""" try: @@ -273,7 +288,7 @@ class AppObjects: users = self.db.getRecordset(UserInDB) if not users: return None - + # Find user by ID for user_dict in users: if user_dict.get("id") == userId: @@ -282,9 +297,9 @@ class AppObjects: if filteredUsers: return User.from_dict(filteredUsers[0]) return None - + return None - + except Exception as e: logger.error(f"Error getting user by ID: {str(e)}") return None @@ -292,42 +307,50 @@ class AppObjects: def authenticateLocalUser(self, username: str, password: str) -> Optional[User]: """Authenticates a user by username and password using local authentication.""" # Clear the users table from cache and reload it - + # Get user by username user = self.getUserByUsername(username) - + if not user: raise ValueError("User not found") - + # Check if the user is enabled if not user.enabled: raise ValueError("User is disabled") - + # Verify that the user has local authentication enabled if user.authenticationAuthority != AuthAuthority.LOCAL: raise ValueError("User does not have local authentication enabled") - + # Get the full user record with password hash for verification userRecord = self.db.getRecordset(UserInDB, recordFilter={"id": user.id})[0] if not userRecord.get("hashedPassword"): raise ValueError("User has no password set") - + if not self._verifyPassword(password, userRecord["hashedPassword"]): raise ValueError("Invalid password") - + return user - def createUser(self, username: str, password: str = None, email: str = None, - fullName: str = None, language: str = "en", enabled: bool = True, - privilege: UserPrivilege = UserPrivilege.USER, - authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, - externalId: str = None, externalUsername: str = None, - externalEmail: str = None) -> User: + def createUser( + self, + username: str, + password: str = None, + email: str = None, + fullName: str = None, + language: str = "en", + enabled: bool = True, + privilege: UserPrivilege = UserPrivilege.USER, + authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, + externalId: str = None, + externalUsername: str = None, + externalEmail: str = None, + ) -> User: """Create a new user with optional external connection""" try: # Ensure username is a string username = str(username).strip() - + # Validate password for local authentication if authenticationAuthority == AuthAuthority.LOCAL: if not password: @@ -336,7 +359,7 @@ class AppObjects: raise ValueError("Password must be a string") if not password.strip(): raise ValueError("Password cannot be empty") - + # Create user data using UserInDB model userData = UserInDB( username=username, @@ -348,15 +371,14 @@ class AppObjects: privilege=privilege, authenticationAuthority=authenticationAuthority, hashedPassword=self._getPasswordHash(password) if password else None, - connections=[] + connections=[], ) - + # Create user record createdRecord = self.db.recordCreate(UserInDB, userData) if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create user record") - - + # Add external connection if provided if externalId and externalUsername: self.addUserConnection( @@ -364,18 +386,20 @@ class AppObjects: authenticationAuthority, externalId, externalUsername, - externalEmail + externalEmail, ) - + # Get created user using the returned ID - createdUser = self.db.getRecordset(UserInDB, recordFilter={"id": createdRecord["id"]}) + createdUser = self.db.getRecordset( + UserInDB, recordFilter={"id": createdRecord["id"]} + ) if not createdUser or len(createdUser) == 0: raise ValueError("Failed to retrieve created user") - + # Clear cache to ensure fresh data (already done above) - + return User.from_dict(createdUser[0]) - + except ValueError as e: logger.error(f"Error creating user: {str(e)}") raise @@ -390,23 +414,22 @@ class AppObjects: user = self.getUser(userId) if not user: raise ValueError(f"User {userId} not found") - + # Update user data using model updatedData = user.to_dict() updatedData.update(updateData) updatedUser = User.from_dict(updatedData) - + # Update user record self.db.recordModify(UserInDB, userId, updatedUser) - - + # Get updated user updatedUser = self.getUser(userId) if not updatedUser: raise ValueError("Failed to retrieve updated user") - + return updatedUser - + except Exception as e: logger.error(f"Error updating user: {str(e)}") raise ValueError(f"Failed to update user: {str(e)}") @@ -414,34 +437,33 @@ class AppObjects: def disableUser(self, userId: str) -> User: """Disables a user if current user has permission.""" return self.updateUser(userId, {"enabled": False}) - + def enableUser(self, userId: str) -> User: """Enables a user if current user has permission.""" return self.updateUser(userId, {"enabled": True}) - + def _deleteUserReferencedData(self, userId: str) -> None: """Deletes all data associated with a user.""" try: - - # Delete user auth events events = self.db.getRecordset(AuthEvent, recordFilter={"userId": userId}) for event in events: self.db.recordDelete(AuthEvent, event["id"]) - + # Delete user tokens tokens = self.db.getRecordset(Token, recordFilter={"userId": userId}) for token in tokens: self.db.recordDelete(Token, token["id"]) - - + # Delete user connections - connections = self.db.getRecordset(UserConnection, recordFilter={"userId": userId}) + connections = self.db.getRecordset( + UserConnection, recordFilter={"userId": userId} + ) for conn in connections: self.db.recordDelete(UserConnection, conn["id"]) - + logger.info(f"All referenced data for user {userId} has been deleted") - + except Exception as e: logger.error(f"Error deleting referenced data for user {userId}: {str(e)}") raise @@ -453,22 +475,21 @@ class AppObjects: user = self.getUser(userId) if not user: raise ValueError(f"User {userId} not found") - + if not self._canModify(UserInDB, userId): raise PermissionError(f"No permission to delete user {userId}") - + # Delete all referenced data first self._deleteUserReferencedData(userId) - + # Delete user record success = self.db.recordDelete(UserInDB, userId) if not success: raise ValueError(f"Failed to delete user {userId}") - - + logger.info(f"User {userId} successfully deleted") return True - + except Exception as e: logger.error(f"Error deleting user: {str(e)}") raise ValueError(f"Failed to delete user: {str(e)}") @@ -479,7 +500,7 @@ class AppObjects: initialUserId = self.getInitialId(UserInDB) if not initialUserId: return None - + users = self.db.getRecordset(UserInDB, recordFilter={"id": initialUserId}) return users[0] if users else None except Exception as e: @@ -491,33 +512,24 @@ class AppObjects: try: username = checkData.get("username") authenticationAuthority = checkData.get("authenticationAuthority", "local") - + if not username: - return { - "available": False, - "message": "Username is required" - } - + return {"available": False, "message": "Username is required"} + # Get user by username user = self.getUserByUsername(username) - + # Check if user exists (User model instance) if user is not None: - return { - "available": False, - "message": "Username is already taken" - } - - return { - "available": True, - "message": "Username is available" - } - + return {"available": False, "message": "Username is already taken"} + + return {"available": True, "message": "Username is available"} + except Exception as e: logger.error(f"Error checking username availability: {str(e)}") return { "available": False, - "message": f"Error checking username availability: {str(e)}" + "message": f"Error checking username availability: {str(e)}", } # Connection methods @@ -526,8 +538,10 @@ class AppObjects: """Returns all connections for a user.""" try: # Get connections for this user - connections = self.db.getRecordset(UserConnection, recordFilter={"userId": userId}) - + connections = self.db.getRecordset( + UserConnection, recordFilter={"userId": userId} + ) + # Convert to UserConnection objects result = [] for conn_dict in connections: @@ -543,24 +557,32 @@ class AppObjects: status=conn_dict.get("status", "pending"), connectedAt=conn_dict.get("connectedAt"), lastChecked=conn_dict.get("lastChecked"), - expiresAt=conn_dict.get("expiresAt") + expiresAt=conn_dict.get("expiresAt"), ) result.append(connection) except Exception as e: - logger.error(f"Error converting connection dict to object: {str(e)}") + logger.error( + f"Error converting connection dict to object: {str(e)}" + ) continue return result - + except Exception as e: logger.error(f"Error getting user connections: {str(e)}") return [] - def addUserConnection(self, userId: str, authority: AuthAuthority, externalId: str, - externalUsername: str, externalEmail: Optional[str] = None, - status: ConnectionStatus = ConnectionStatus.PENDING) -> UserConnection: + def addUserConnection( + self, + userId: str, + authority: AuthAuthority, + externalId: str, + externalUsername: str, + externalEmail: Optional[str] = None, + status: ConnectionStatus = ConnectionStatus.PENDING, + ) -> UserConnection: """ Adds a new connection for a user. - + Args: userId: The ID of the user authority: The authentication authority (e.g., MSFT, GOOGLE) @@ -568,7 +590,7 @@ class AppObjects: externalUsername: The username from the authority externalEmail: Optional email from the authority status: The connection status (defaults to PENDING) - + Returns: The created UserConnection object """ @@ -577,7 +599,7 @@ class AppObjects: user = self.getUser(userId) if not user: raise ValueError(f"User not found: {userId}") - + # Create new connection with all required fields connection = UserConnection( id=str(uuid.uuid4()), @@ -589,15 +611,14 @@ class AppObjects: status=status, connectedAt=get_utc_timestamp(), lastChecked=get_utc_timestamp(), - expiresAt=None # Optional field, set to None by default + expiresAt=None, # Optional field, set to None by default ) - + # Save to connections table self.db.recordCreate(UserConnection, connection) - - + return connection - + except Exception as e: logger.error(f"Error adding user connection: {str(e)}") raise ValueError(f"Failed to add user connection: {str(e)}") @@ -606,93 +627,88 @@ class AppObjects: """Remove a connection to an external service""" try: # Get connection - connections = self.db.getRecordset(UserConnection, recordFilter={ - "id": connectionId - }) - + connections = self.db.getRecordset( + UserConnection, recordFilter={"id": connectionId} + ) + if not connections: raise ValueError(f"Connection {connectionId} not found") - + # Delete connection self.db.recordDelete(UserConnection, connectionId) - - + except Exception as e: logger.error(f"Error removing user connection: {str(e)}") raise ValueError(f"Failed to remove user connection: {str(e)}") # Mandate methods - + def getAllMandates(self) -> List[Mandate]: """Returns all mandates based on user access level.""" allMandates = self.db.getRecordset(Mandate) filteredMandates = self._uam(Mandate, allMandates) return [Mandate.from_dict(mandate) for mandate in filteredMandates] - + def getMandate(self, mandateId: str) -> Optional[Mandate]: """Returns a mandate by ID if user has access.""" mandates = self.db.getRecordset(Mandate, recordFilter={"id": mandateId}) if not mandates: return None - + filteredMandates = self._uam(Mandate, mandates) if not filteredMandates: return None - + return Mandate.from_dict(filteredMandates[0]) - + def createMandate(self, name: str, language: str = "en") -> Mandate: """Creates a new mandate if user has permission.""" if not self._canModify(Mandate): raise PermissionError("No permission to create mandates") - + # Create mandate data using model - mandateData = Mandate( - name=name, - language=language - ) - + mandateData = Mandate(name=name, language=language) + # Create mandate record createdRecord = self.db.recordCreate(Mandate, mandateData) if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create mandate record") - - + return Mandate.from_dict(createdRecord) - + def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate: """Updates a mandate if user has access.""" try: # First check if user has permission to modify mandates if not self._canModify(Mandate, mandateId): raise PermissionError(f"No permission to update mandate {mandateId}") - + # Get mandate with access control mandate = self.getMandate(mandateId) if not mandate: raise ValueError(f"Mandate {mandateId} not found") - + # Update mandate data using model updatedData = mandate.to_dict() updatedData.update(updateData) updatedMandate = Mandate.from_dict(updatedData) - + # Update mandate record self.db.recordModify(Mandate, mandateId, updatedMandate) - + # Clear cache to ensure fresh data - + # Get updated mandate updatedMandate = self.getMandate(mandateId) if not updatedMandate: raise ValueError("Failed to retrieve updated mandate") - + return updatedMandate - + except Exception as e: logger.error(f"Error updating mandate: {str(e)}") raise ValueError(f"Failed to update mandate: {str(e)}") - + def deleteMandate(self, mandateId: str) -> bool: """Deletes a mandate if user has access.""" try: @@ -700,22 +716,24 @@ class AppObjects: mandate = self.getMandate(mandateId) if not mandate: return False - + if not self._canModify(Mandate, mandateId): raise PermissionError(f"No permission to delete mandate {mandateId}") - + # Check if mandate has users users = self.getUsersByMandate(mandateId) if users: - raise ValueError(f"Cannot delete mandate {mandateId} with existing users") - + raise ValueError( + f"Cannot delete mandate {mandateId} with existing users" + ) + # Delete mandate success = self.db.recordDelete(Mandate, mandateId) - + # Clear cache to ensure fresh data - + return success - + except Exception as e: logger.error(f"Error deleting mandate: {str(e)}") raise ValueError(f"Failed to delete mandate: {str(e)}") @@ -727,51 +745,64 @@ class AppObjects: try: # Validate that this is NOT a connection token if token.connectionId: - raise ValueError("Access tokens cannot have connectionId - use saveConnectionToken instead") - + raise ValueError( + "Access tokens cannot have connectionId - use saveConnectionToken instead" + ) + # Validate user context if not self.currentUser or not self.currentUser.id: raise ValueError("No valid user context available for token storage") - + # Set the user ID and mandate ID token.userId = self.currentUser.id - + # Ensure token has required fields if not token.id: token.id = str(uuid.uuid4()) if not token.createdAt: token.createdAt = get_utc_timestamp() - + # If replace_existing is True, delete old access tokens for this user and authority first if replace_existing: try: - old_tokens = self.db.getRecordset(Token, recordFilter={ - "userId": self.currentUser.id, - "authority": token.authority, - "connectionId": None # Ensure we only delete access tokens - }) + old_tokens = self.db.getRecordset( + Token, + recordFilter={ + "userId": self.currentUser.id, + "authority": token.authority, + "connectionId": None, # Ensure we only delete access tokens + }, + ) deleted_count = 0 for old_token in old_tokens: - if old_token["id"] != token.id: # Don't delete the new token if it already exists + if ( + old_token["id"] != token.id + ): # Don't delete the new token if it already exists self.db.recordDelete(Token, old_token["id"]) deleted_count += 1 - + if deleted_count > 0: - logger.info(f"Replaced {deleted_count} old access tokens for user {self.currentUser.id} and authority {token.authority}") - + logger.info( + f"Replaced {deleted_count} old access tokens for user {self.currentUser.id} and authority {token.authority}" + ) + except Exception as e: - logger.warning(f"Failed to delete old access tokens for user {self.currentUser.id} and authority {token.authority}: {str(e)}") + logger.warning( + f"Failed to delete old access tokens for user {self.currentUser.id} and authority {token.authority}: {str(e)}" + ) # Continue with saving the new token even if deletion fails - + # Convert to dict and ensure all fields are properly set - token_dict = token.dict() + token_dict = token.model_dump() + # Ensure userId is set to current user + # Convert to dict and ensure all fields are properly set + token_dict = token.model_dump() # Ensure userId is set to current user token_dict["userId"] = self.currentUser.id - + # Save to database self.db.recordCreate(Token, token_dict) - - + except Exception as e: logger.error(f"Error saving access token: {str(e)}") raise @@ -781,49 +812,56 @@ class AppObjects: try: # Validate that this IS a connection token if not token.connectionId: - raise ValueError("Connection tokens must have connectionId - use saveAccessToken instead") - + raise ValueError( + "Connection tokens must have connectionId - use saveAccessToken instead" + ) + # Validate user context if not self.currentUser or not self.currentUser.id: raise ValueError("No valid user context available for token storage") - + # Set the user ID for the connection token token.userId = self.currentUser.id - + # Ensure token has required fields if not token.id: token.id = str(uuid.uuid4()) if not token.createdAt: token.createdAt = get_utc_timestamp() - + # If replace_existing is True, delete old tokens for this connectionId first if replace_existing: try: - old_tokens = self.db.getRecordset(Token, recordFilter={ - "connectionId": token.connectionId - }) + old_tokens = self.db.getRecordset( + Token, recordFilter={"connectionId": token.connectionId} + ) deleted_count = 0 for old_token in old_tokens: - if old_token["id"] != token.id: # Don't delete the new token if it already exists + if ( + old_token["id"] != token.id + ): # Don't delete the new token if it already exists self.db.recordDelete(Token, old_token["id"]) deleted_count += 1 - + if deleted_count > 0: - logger.info(f"Replaced {deleted_count} old tokens for connectionId {token.connectionId}") - + logger.info( + f"Replaced {deleted_count} old tokens for connectionId {token.connectionId}" + ) + except Exception as e: - logger.warning(f"Failed to delete old tokens for connectionId {token.connectionId}: {str(e)}") + logger.warning( + f"Failed to delete old tokens for connectionId {token.connectionId}: {str(e)}" + ) # Continue with saving the new token even if deletion fails - + # Convert to dict and ensure all fields are properly set - token_dict = token.dict() + token_dict = token.model_dump() # Ensure userId is set to current user token_dict["userId"] = self.currentUser.id - + # Save to database self.db.recordCreate(Token, token_dict) - - + except Exception as e: logger.error(f"Error saving connection token: {str(e)}") raise @@ -834,37 +872,49 @@ class AppObjects: # Validate connectionId if not connectionId: raise ValueError("connectionId is required for getConnectionToken") - + # Get token for this specific connection # Query for specific connection - tokens = self.db.getRecordset(Token, recordFilter={ - "connectionId": connectionId - }) - - + tokens = self.db.getRecordset( + Token, recordFilter={"connectionId": connectionId} + ) + if not tokens: - logger.warning(f"No connection token found for connectionId: {connectionId}") + logger.warning( + f"No connection token found for connectionId: {connectionId}" + ) return None - + # Sort by expiration date and get the latest (most recent expiration) tokens.sort(key=lambda x: x.get("expiresAt", 0), reverse=True) latest_token = Token(**tokens[0]) - + # No auto-refresh here. Callers should use a higher-level service to refresh when needed. - + return latest_token - + except Exception as e: - logger.error(f"Error getting connection token for connectionId {connectionId}: {str(e)}") + logger.error( + f"Error getting connection token for connectionId {connectionId}: {str(e)}" + ) return None - def findActiveTokenById(self, tokenId: str, userId: str, authority: AuthAuthority, sessionId: str = None, mandateId: str = None) -> Optional[Token]: + def findActiveTokenById( + self, + tokenId: str, + userId: str, + authority: AuthAuthority, + sessionId: str = None, + mandateId: str = None, + ) -> Optional[Token]: """Find an active access token by its id (jti) with optional session/tenant scoping.""" try: recordFilter = { "id": tokenId, "userId": userId, - "authority": authority.value if hasattr(authority, 'value') else str(authority), + "authority": authority.value + if hasattr(authority, "value") + else str(authority), "status": TokenStatus.ACTIVE, } if sessionId is not None: @@ -892,7 +942,7 @@ class AppObjects: "status": TokenStatus.REVOKED, "revokedAt": get_utc_timestamp(), "revokedBy": revokedBy, - "reason": reason or "revoked" + "reason": reason or "revoked", } self.db.recordModify(Token, tokenId, tokenUpdate) return True @@ -900,30 +950,53 @@ class AppObjects: logger.error(f"Error revoking token {tokenId}: {str(e)}") return False - def revokeTokensBySessionId(self, sessionId: str, userId: str, authority: AuthAuthority, revokedBy: str, reason: str = None) -> int: + def revokeTokensBySessionId( + self, + sessionId: str, + userId: str, + authority: AuthAuthority, + revokedBy: str, + reason: str = None, + ) -> int: """Revoke all tokens of a session for a user/authority.""" try: - tokens = self.db.getRecordset(Token, recordFilter={ - "userId": userId, - "authority": authority.value if hasattr(authority, 'value') else str(authority), - "sessionId": sessionId, - "status": TokenStatus.ACTIVE - }) + tokens = self.db.getRecordset( + Token, + recordFilter={ + "userId": userId, + "authority": authority.value + if hasattr(authority, "value") + else str(authority), + "sessionId": sessionId, + "status": TokenStatus.ACTIVE, + }, + ) count = 0 for t in tokens: - self.db.recordModify(Token, t["id"], { - "status": TokenStatus.REVOKED, - "revokedAt": get_utc_timestamp(), - "revokedBy": revokedBy, - "reason": reason or "session logout" - }) + self.db.recordModify( + Token, + t["id"], + { + "status": TokenStatus.REVOKED, + "revokedAt": get_utc_timestamp(), + "revokedBy": revokedBy, + "reason": reason or "session logout", + }, + ) count += 1 return count except Exception as e: logger.error(f"Error revoking tokens for session {sessionId}: {str(e)}") return 0 - def revokeTokensByUser(self, userId: str, authority: AuthAuthority = None, mandateId: str = None, revokedBy: str = None, reason: str = None) -> int: + def revokeTokensByUser( + self, + userId: str, + authority: AuthAuthority = None, + mandateId: str = None, + revokedBy: str = None, + reason: str = None, + ) -> int: """Revoke all active tokens for a user, optionally filtered by authority/mandate.""" try: # Fetch all active tokens for user (optionally filtered by authority) @@ -932,16 +1005,22 @@ class AppObjects: "status": TokenStatus.ACTIVE, } if authority is not None: - recordFilter["authority"] = authority.value if hasattr(authority, 'value') else str(authority) + recordFilter["authority"] = ( + authority.value if hasattr(authority, "value") else str(authority) + ) tokens = self.db.getRecordset(Token, recordFilter=recordFilter) count = 0 for t in tokens: - self.db.recordModify(Token, t["id"], { - "status": TokenStatus.REVOKED, - "revokedAt": get_utc_timestamp(), - "revokedBy": revokedBy, - "reason": reason or "admin revoke" - }) + self.db.recordModify( + Token, + t["id"], + { + "status": TokenStatus.REVOKED, + "revokedAt": get_utc_timestamp(), + "revokedBy": revokedBy, + "reason": reason or "admin revoke", + }, + ) count += 1 return count except Exception as e: @@ -953,22 +1032,25 @@ class AppObjects: try: current_time = get_utc_timestamp() cleaned_count = 0 - + # Get all tokens all_tokens = self.db.getRecordset(Token, recordFilter={}) - + for token_data in all_tokens: - if token_data.get("expiresAt") and token_data.get("expiresAt") < current_time: + if ( + token_data.get("expiresAt") + and token_data.get("expiresAt") < current_time + ): # Token is expired, delete it self.db.recordDelete(Token, token_data["id"]) cleaned_count += 1 - + # Clear cache to ensure fresh data if cleaned_count > 0: logger.info(f"Cleaned up {cleaned_count} expired tokens") - + return cleaned_count - + except Exception as e: logger.error(f"Error cleaning up expired tokens: {str(e)}") return 0 @@ -981,79 +1063,92 @@ class AppObjects: self.userId = None self.mandateId = None self.access = None - + # Clear database context - if hasattr(self, 'db'): + if hasattr(self, "db"): self.db.updateContext("") - + logger.info("User logged out successfully") - + except Exception as e: logger.error(f"Error during logout: {str(e)}") raise # Neutralization methods - + def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]: """Get the data neutralization configuration for the current user's mandate""" try: - configs = self.db.getRecordset(DataNeutraliserConfig, recordFilter={"mandateId": self.mandateId}) + configs = self.db.getRecordset( + DataNeutraliserConfig, recordFilter={"mandateId": self.mandateId} + ) if not configs: return None - + # Apply access control filtered_configs = self._uam(DataNeutraliserConfig, configs) if not filtered_configs: return None - + return DataNeutraliserConfig.from_dict(filtered_configs[0]) - + except Exception as e: logger.error(f"Error getting neutralization config: {str(e)}") return None - def createOrUpdateNeutralizationConfig(self, config_data: Dict[str, Any]) -> DataNeutraliserConfig: + def createOrUpdateNeutralizationConfig( + self, config_data: Dict[str, Any] + ) -> DataNeutraliserConfig: """Create or update the data neutralization configuration""" try: # Check if config already exists existing_config = self.getNeutralizationConfig() - + if existing_config: # Update existing config update_data = existing_config.to_dict() update_data.update(config_data) update_data["updatedAt"] = get_utc_timestamp() - + updated_config = DataNeutraliserConfig.from_dict(update_data) - self.db.recordModify(DataNeutraliserConfig, existing_config.id, updated_config) - + self.db.recordModify( + DataNeutraliserConfig, existing_config.id, updated_config + ) + return updated_config else: # Create new config config_data["mandateId"] = self.mandateId config_data["userId"] = self.userId - + new_config = DataNeutraliserConfig.from_dict(config_data) created_record = self.db.recordCreate(DataNeutraliserConfig, new_config) - + return DataNeutraliserConfig.from_dict(created_record) - + except Exception as e: logger.error(f"Error creating/updating neutralization config: {str(e)}") raise ValueError(f"Failed to create/update neutralization config: {str(e)}") - def getNeutralizationAttributes(self, file_id: Optional[str] = None) -> List[DataNeutralizerAttributes]: + def getNeutralizationAttributes( + self, file_id: Optional[str] = None + ) -> List[DataNeutralizerAttributes]: """Get neutralization attributes, optionally filtered by file ID""" try: filter_dict = {"mandateId": self.mandateId} if file_id: filter_dict["fileId"] = file_id - - attributes = self.db.getRecordset(DataNeutralizerAttributes, recordFilter=filter_dict) + + attributes = self.db.getRecordset( + DataNeutralizerAttributes, recordFilter=filter_dict + ) filtered_attributes = self._uam(DataNeutralizerAttributes, attributes) - - return [DataNeutralizerAttributes.from_dict(attr) for attr in filtered_attributes] - + + return [ + DataNeutralizerAttributes.from_dict(attr) + for attr in filtered_attributes + ] + except Exception as e: logger.error(f"Error getting neutralization attributes: {str(e)}") return [] @@ -1061,23 +1156,27 @@ class AppObjects: def deleteNeutralizationAttributes(self, file_id: str) -> bool: """Delete all neutralization attributes for a specific file""" try: - attributes = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={ - "mandateId": self.mandateId, - "fileId": file_id - }) - + attributes = self.db.getRecordset( + DataNeutralizerAttributes, + recordFilter={"mandateId": self.mandateId, "fileId": file_id}, + ) + for attribute in attributes: self.db.recordDelete(DataNeutralizerAttributes, attribute["id"]) - - logger.info(f"Deleted {len(attributes)} neutralization attributes for file {file_id}") + + logger.info( + f"Deleted {len(attributes)} neutralization attributes for file {file_id}" + ) return True - + except Exception as e: logger.error(f"Error deleting neutralization attributes: {str(e)}") return False + # Public Methods + def getInterface(currentUser: User) -> AppObjects: """ Returns a AppObjects instance for the current user. @@ -1085,46 +1184,49 @@ def getInterface(currentUser: User) -> AppObjects: """ if not currentUser: raise ValueError("Invalid user context: user is required") - + # Create context key contextKey = f"{currentUser.mandateId}_{currentUser.id}" - + # Create new instance if not exists if contextKey not in _gatewayInterfaces: _gatewayInterfaces[contextKey] = AppObjects(currentUser) - + return _gatewayInterfaces[contextKey] + def getRootInterface() -> AppObjects: """ Returns a AppObjects instance with root privileges. This is used for initial setup and user creation. """ global _rootAppObjects - + if _rootAppObjects is None: try: # Create a temporary interface without user context to get root user tempInterface = AppObjects() - + # Get the initial user directly initialUserId = tempInterface.getInitialId(UserInDB) if not initialUserId: raise ValueError("No initial user ID found in database") - - users = tempInterface.db.getRecordset(UserInDB, recordFilter={"id": initialUserId}) + + users = tempInterface.db.getRecordset( + UserInDB, recordFilter={"id": initialUserId} + ) if not users: raise ValueError("Initial user not found in database") - + # Convert to User model user_data = users[0] - rootUser = User.parse_obj(user_data) - + rootUser = User.model_validate(user_data) + # Create root interface with the root user _rootAppObjects = AppObjects(rootUser) - + except Exception as e: logger.error(f"Error getting root user: {str(e)}") raise ValueError(f"Failed to get root user: {str(e)}") - + return _rootAppObjects diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 551a557d..cb67bef9 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -2,52 +2,55 @@ Shared utilities for model attributes and labels. """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from typing import Dict, Any, List, Type, Optional, Union import inspect import importlib import os from datetime import datetime + class ModelMixin: """Mixin class that provides serialization methods for Pydantic models.""" - + def to_dict(self) -> Dict[str, Any]: """ Convert a Pydantic model to a dictionary. Handles both Pydantic v1 and v2. All timestamp fields remain as float values. - + Returns: Dict[str, Any]: Dictionary representation of the model """ # Get the raw dictionary - if hasattr(self, 'model_dump'): + if hasattr(self, "model_dump"): data: Dict[str, Any] = self.model_dump() # Pydantic v2 else: data: Dict[str, Any] = self.dict() # Pydantic v1 - + # All fields (including timestamps) remain in their original format # No conversions needed - timestamps are already float - + return data - + @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'ModelMixin': + def from_dict(cls, data: Dict[str, Any]) -> "ModelMixin": """ Create a Pydantic model instance from a dictionary. - + Args: data: Dictionary containing the model data - + Returns: ModelMixin: New instance of the model class """ return cls(**data) + # Define the AttributeDefinition class here instead of importing it class AttributeDefinition(BaseModel, ModelMixin): """Definition of a model attribute with its metadata.""" + name: str type: str label: str @@ -64,41 +67,47 @@ class AttributeDefinition(BaseModel, ModelMixin): order: int = 0 placeholder: Optional[str] = None + # Global registry for model labels MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {} + def to_dict(model: BaseModel) -> Dict[str, Any]: """ Convert a Pydantic model to a dictionary. Handles both Pydantic v1 and v2. - + Args: model: The Pydantic model instance to convert - + Returns: Dict[str, Any]: Dictionary representation of the model """ - if hasattr(model, 'model_dump'): + if hasattr(model, "model_dump"): return model.model_dump() # Pydantic v2 return model.dict() # Pydantic v1 + def from_dict(model_class: Type[BaseModel], data: Dict[str, Any]) -> BaseModel: """ Create a Pydantic model instance from a dictionary. - + Args: model_class: The Pydantic model class to instantiate data: Dictionary containing the model data - + Returns: BaseModel: New instance of the model class """ return model_class(**data) -def register_model_labels(model_name: str, model_label: Dict[str, str], labels: Dict[str, Dict[str, str]]): + +def register_model_labels( + model_name: str, model_label: Dict[str, str], labels: Dict[str, Dict[str, str]] +): """ Register labels for a model's attributes and the model itself. - + Args: model_name: Name of the model class model_label: Dictionary mapping language codes to model labels @@ -106,38 +115,37 @@ def register_model_labels(model_name: str, model_label: Dict[str, str], labels: labels: Dictionary mapping attribute names to their translations e.g. {"name": {"en": "Name", "fr": "Nom"}} """ - MODEL_LABELS[model_name] = { - "model": model_label, - "attributes": labels - } + MODEL_LABELS[model_name] = {"model": model_label, "attributes": labels} + def get_model_labels(model_name: str, language: str = "en") -> Dict[str, str]: """ Get labels for a model's attributes in the specified language. - + Args: model_name: Name of the model class language: Language code (default: "en") - + Returns: Dictionary mapping attribute names to their labels in the specified language """ model_data = MODEL_LABELS.get(model_name, {}) attribute_labels = model_data.get("attributes", {}) - + return { attr: translations.get(language, translations.get("en", attr)) for attr, translations in attribute_labels.items() } + def get_model_label(model_name: str, language: str = "en") -> str: """ Get the label for a model in the specified language. - + Args: model_name: Name of the model class language: Language code (default: "en") - + Returns: Model label in the specified language, or model name if no label exists """ @@ -145,156 +153,205 @@ def get_model_label(model_name: str, language: str = "en") -> str: model_label = model_data.get("model", {}) return model_label.get(language, model_label.get("en", model_name)) -def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]: + +def getModelAttributeDefinitions( + modelClass: Type[BaseModel] = None, userLanguage: str = "en" +) -> Dict[str, Any]: """ Get attribute definitions for a model class. - + Args: modelClass: The model class to get attributes for userLanguage: Language code for translations (default: "en") - + Returns: Dictionary containing model label and attribute definitions """ if not modelClass: return {} - + attributes = [] model_name = modelClass.__name__ labels = get_model_labels(model_name, userLanguage) model_label = get_model_label(model_name, userLanguage) - + # Handle both Pydantic v1 and v2 - if hasattr(modelClass, 'model_fields'): # Pydantic v2 + if hasattr(modelClass, "model_fields"): # Pydantic v2 fields = modelClass.model_fields for name, field in fields.items(): # Extract frontend metadata from field info - field_info = field.field_info if hasattr(field, 'field_info') else None + field_info = field.field_info if hasattr(field, "field_info") else None # Check both direct attributes and extra field for frontend metadata frontend_type = None frontend_readonly = False frontend_required = field.is_required() frontend_options = None - + if field_info: # Try direct attributes first - frontend_type = getattr(field_info, 'frontend_type', None) - frontend_readonly = getattr(field_info, 'frontend_readonly', False) - frontend_required = getattr(field_info, 'frontend_required', frontend_required) - frontend_options = getattr(field_info, 'frontend_options', None) - + frontend_type = getattr(field_info, "frontend_type", None) + frontend_readonly = getattr(field_info, "frontend_readonly", False) + frontend_required = getattr( + field_info, "frontend_required", frontend_required + ) + frontend_options = getattr(field_info, "frontend_options", None) + # If not found, check extra field - if hasattr(field_info, 'extra') and field_info.extra: + if hasattr(field_info, "extra") and field_info.extra: if frontend_type is None: - frontend_type = field_info.extra.get('frontend_type') + frontend_type = field_info.extra.get("frontend_type") if not frontend_readonly: - frontend_readonly = field_info.extra.get('frontend_readonly', False) - if frontend_required == field.is_required(): # Only override if we didn't get it from direct attribute - frontend_required = field_info.extra.get('frontend_required', frontend_required) + frontend_readonly = field_info.extra.get( + "frontend_readonly", False + ) + if ( + frontend_required == field.is_required() + ): # Only override if we didn't get it from direct attribute + frontend_required = field_info.extra.get( + "frontend_required", frontend_required + ) if frontend_options is None: - frontend_options = field_info.extra.get('frontend_options') - + frontend_options = field_info.extra.get("frontend_options") + # Use frontend type if available, otherwise fall back to Python type - field_type = frontend_type if frontend_type else (field.annotation.__name__ if hasattr(field.annotation, "__name__") else str(field.annotation)) - - attributes.append({ - "name": name, - "type": field_type, - "required": frontend_required, - "description": field.description if hasattr(field, "description") else "", - "label": labels.get(name, name), - "placeholder": f"Please enter {labels.get(name, name)}", - "editable": not frontend_readonly, - "visible": True, - "order": len(attributes), - "readonly": frontend_readonly, - "options": frontend_options - }) + field_type = ( + frontend_type + if frontend_type + else ( + field.annotation.__name__ + if hasattr(field.annotation, "__name__") + else str(field.annotation) + ) + ) + + attributes.append( + { + "name": name, + "type": field_type, + "required": frontend_required, + "description": field.description + if hasattr(field, "description") + else "", + "label": labels.get(name, name), + "placeholder": f"Please enter {labels.get(name, name)}", + "editable": not frontend_readonly, + "visible": True, + "order": len(attributes), + "readonly": frontend_readonly, + "options": frontend_options, + } + ) else: # Pydantic v1 fields = modelClass.__fields__ for name, field in fields.items(): # Extract frontend metadata from field info - field_info = field.field_info if hasattr(field, 'field_info') else None + field_info = field.field_info if hasattr(field, "field_info") else None # Check both direct attributes and extra field for frontend metadata frontend_type = None frontend_readonly = False frontend_required = field.required frontend_options = None - + if field_info: # Try direct attributes first - frontend_type = getattr(field_info, 'frontend_type', None) - frontend_readonly = getattr(field_info, 'frontend_readonly', False) - frontend_required = getattr(field_info, 'frontend_required', frontend_required) - frontend_options = getattr(field_info, 'frontend_options', None) - + frontend_type = getattr(field_info, "frontend_type", None) + frontend_readonly = getattr(field_info, "frontend_readonly", False) + frontend_required = getattr( + field_info, "frontend_required", frontend_required + ) + frontend_options = getattr(field_info, "frontend_options", None) + # If not found, check extra field - if hasattr(field_info, 'extra') and field_info.extra: + if hasattr(field_info, "extra") and field_info.extra: if frontend_type is None: - frontend_type = field_info.extra.get('frontend_type') + frontend_type = field_info.extra.get("frontend_type") if not frontend_readonly: - frontend_readonly = field_info.extra.get('frontend_readonly', False) - if frontend_required == field.required: # Only override if we didn't get it from direct attribute - frontend_required = field_info.extra.get('frontend_required', frontend_required) + frontend_readonly = field_info.extra.get( + "frontend_readonly", False + ) + if ( + frontend_required == field.required + ): # Only override if we didn't get it from direct attribute + frontend_required = field_info.extra.get( + "frontend_required", frontend_required + ) if frontend_options is None: - frontend_options = field_info.extra.get('frontend_options') - + frontend_options = field_info.extra.get("frontend_options") + # Use frontend type if available, otherwise fall back to Python type - field_type = frontend_type if frontend_type else (field.type_.__name__ if hasattr(field.type_, "__name__") else str(field.type_)) - - attributes.append({ - "name": name, - "type": field_type, - "required": frontend_required, - "description": field.field_info.description if hasattr(field.field_info, "description") else "", - "label": labels.get(name, name), - "placeholder": f"Please enter {labels.get(name, name)}", - "editable": not frontend_readonly, - "visible": True, - "order": len(attributes), - "readonly": frontend_readonly, - "options": frontend_options - }) - - return { - "model": model_label, - "attributes": attributes - } + field_type = ( + frontend_type + if frontend_type + else ( + field.type_.__name__ + if hasattr(field.type_, "__name__") + else str(field.type_) + ) + ) + + attributes.append( + { + "name": name, + "type": field_type, + "required": frontend_required, + "description": field.field_info.description + if hasattr(field.field_info, "description") + else "", + "label": labels.get(name, name), + "placeholder": f"Please enter {labels.get(name, name)}", + "editable": not frontend_readonly, + "visible": True, + "order": len(attributes), + "readonly": frontend_readonly, + "options": frontend_options, + } + ) + + return {"model": model_label, "attributes": attributes} + def getModelClasses() -> Dict[str, Type[BaseModel]]: """ Dynamically get all model classes from all model modules. - + Returns: Dict[str, Type[BaseModel]]: Dictionary of model class names to their classes """ modelClasses = {} - + # Get the interfaces directory path - interfaces_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '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'): + 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}') - + 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: + if ( + inspect.isclass(obj) + and issubclass(obj, BaseModel) + and obj != BaseModel + ): modelClasses[name] = obj - + return modelClasses + class AttributeResponse(BaseModel): """Response model for entity attributes""" + attributes: List[AttributeDefinition] - - class Config: - schema_extra = { + + model_config = ConfigDict( + json_schema_extra={ "example": { "attributes": [ { @@ -305,8 +362,9 @@ class AttributeResponse(BaseModel): "placeholder": "Please enter username", "editable": True, "visible": True, - "order": 0 + "order": 0, } ] } - } \ No newline at end of file + } + ) diff --git a/requirements.txt b/requirements.txt index f5a1a2dc..28c8bb99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ websockets==12.0 uvicorn==0.23.2 python-multipart==0.0.6 httpx==0.25.0 -pydantic==1.10.13 # Ältere Version ohne Rust-Abhängigkeit +pydantic>=2.0.0 # Upgraded to v2 for LangChain compatibility email-validator==2.0.0 # Required by Pydantic for email validation slowapi==0.1.8 # For rate limiting @@ -108,3 +108,8 @@ xyzservices>=2021.09.1 # PostgreSQL connector dependencies psycopg2-binary==2.9.9 + +## LangChain & LangGraph +langchain==0.3.27 +langgraph==0.6.8 +langchain-core==0.3.77