diff --git a/app.py b/app.py index 89282ca1..9642cdaa 100644 --- a/app.py +++ b/app.py @@ -68,6 +68,14 @@ async def lifespan(app: FastAPI): # Shutdown logic logger.info("Application has been shut down") +# START APP +app = FastAPI( + title="PowerOn | Data Platform API", + description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})", + lifespan=lifespan +) + + # Parse CORS origins from environment variable def get_allowed_origins(): origins_str = APP_CONFIG.get("APP_ALLOWED_ORIGINS", "http://localhost:8080") @@ -76,13 +84,6 @@ def get_allowed_origins(): logger.info(f"CORS allowed origins: {origins}") return origins -# START APP -app = FastAPI( - title="PowerOn | Data Platform API", - description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})", - lifespan=lifespan -) - # CORS configuration using environment variables app.add_middleware( CORSMiddleware, diff --git a/config.ini b/config.ini index 564a0fa4..de1494c6 100644 --- a/config.ini +++ b/config.ini @@ -47,6 +47,6 @@ Agent_Coder_EXECUTION_TIMEOUT = 60 Agent_Coder_EXECUTION_RETRY = 5 # Agent Mail configuration -Agent_Mail_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Agent_Mail_MSFT_CLIENT_SECRET = Kxf8Q~2lJIteZ~JaI32kMf1lfaWKATqxXiNiFbzV -Agent_Mail_MSFT_TENANT_ID = common +Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_CLIENT_SECRET = Kxf8Q~2lJIteZ~JaI32kMf1lfaWKATqxXiNiFbzV +Service_MSFT_TENANT_ID = common diff --git a/env_dev.env b/env_dev.env index 188ce330..fd85ba89 100644 --- a/env_dev.env +++ b/env_dev.env @@ -17,6 +17,12 @@ DB_LUCYDOM_DATABASE=lucydom DB_LUCYDOM_USER=dev_user DB_LUCYDOM_PASSWORD_SECRET=dev_password +# Database Configuration MSFT +DB_MSFT_HOST=D:/Temp/_powerondb +DB_MSFT_DATABASE=msft +DB_MSFT_USER=dev_user +DB_MSFT_PASSWORD_SECRET=dev_password + # Security Configuration APP_JWT_SECRET_SECRET=dev_jwt_secret_token APP_TOKEN_EXPIRY=300 @@ -35,4 +41,4 @@ APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_BACKUP_COUNT = 5 # Agent Mail -Agent_Mail_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback \ No newline at end of file +Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback \ No newline at end of file diff --git a/env_prod.env b/env_prod.env index f18db4af..a3ffc6ed 100644 --- a/env_prod.env +++ b/env_prod.env @@ -17,6 +17,12 @@ DB_LUCYDOM_DATABASE=lucydom DB_LUCYDOM_USER=dev_user DB_LUCYDOM_PASSWORD_SECRET=prod_password +# Database Configuration MSFT +DB_MSFT_HOST=/home/_powerondb +DB_MSFT_DATABASE=msft +DB_MSFT_USER=dev_user +DB_MSFT_PASSWORD_SECRET=dev_password + # Security Configuration APP_JWT_SECRET_SECRET=dev_jwt_secret_token APP_TOKEN_EXPIRY=300 @@ -34,5 +40,5 @@ APP_LOGGING_FILE_ENABLED = True APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_BACKUP_COUNT = 5 -# Agent Mail -Agent_Mail_MSFT_REDIRECT_URI = https://gateway.poweron-center.net/api/msft/auth/callback +# Service MSFT +Service_MSFT_REDIRECT_URI = https://gateway.poweron-center.net/api/msft/auth/callback diff --git a/modules/agents/agentAnalyst.py b/modules/agents/agentAnalyst.py index 1e14332b..04f5b090 100644 --- a/modules/agents/agentAnalyst.py +++ b/modules/agents/agentAnalyst.py @@ -36,8 +36,9 @@ class AgentAnalyst(AgentBase): # Set default visualization settings plt.style.use('seaborn-v0_8-whitegrid') - def setDependencies(self, mydom=None): + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" + self.setService(serviceBase) async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ @@ -56,7 +57,7 @@ class AgentAnalyst(AgentBase): workflow = task.get("context", {}).get("workflow", {}) # Check AI service - if not self.mydom: + if not self.service or not self.service.base: return { "feedback": "The Analyst agent requires an AI service to function effectively.", "documents": [] @@ -244,7 +245,7 @@ class AgentAnalyst(AgentBase): try: # Get analysis plan from AI - response = await self.mydom.callAi([ + response = await self.service.base.callAi([ {"role": "system", "content": "You are a data analysis expert. Create detailed analysis plans. Respond with valid JSON only."}, {"role": "user", "content": analysisPrompt} ], produceUserAnswer=True) @@ -366,7 +367,7 @@ class AgentAnalyst(AgentBase): """ # Get analysis plan from AI - response = await self.mydom.callAi([ + response = await self.service.base.callAi([ {"role": "system", "content": "You are a data analysis expert. Create detailed analysis plans. Respond with valid JSON only."}, {"role": "user", "content": analysisPrompt} ], produceUserAnswer=True) @@ -457,7 +458,7 @@ class AgentAnalyst(AgentBase): if not vizRecommendations: # Generate visualization recommendations if none provided - self.mydom.logAdd(analysisPlan.get("workflowId"), "Generating visualization recommendations...", level="info", progress=50) + self.service.base.logAdd(analysisPlan.get("workflowId"), "Generating visualization recommendations...", level="info", progress=50) vizPrompt = f""" Based on this data and task, recommend appropriate visualizations. @@ -481,7 +482,7 @@ class AgentAnalyst(AgentBase): }} """ - response = await self.mydom.callAi([ + response = await self.service.base.callAi([ {"role": "system", "content": "You are a data visualization expert. Recommend appropriate visualizations based on the data and task."}, {"role": "user", "content": vizPrompt} ]) @@ -566,7 +567,7 @@ class AgentAnalyst(AgentBase): try: # Get visualization code from AI - vizCode = await self.mydom.callAi([ + vizCode = await self.service.base.callAi([ {"role": "system", "content": "You are a data visualization expert. Provide only executable Python code."}, {"role": "user", "content": vizPrompt} ], produceUserAnswer = True) @@ -696,7 +697,7 @@ class AgentAnalyst(AgentBase): try: # Get data processing code from AI - dataCode = await self.mydom.callAi([ + dataCode = await self.service.base.callAi([ {"role": "system", "content": "You are a data processing expert. Provide only executable Python code."}, {"role": "user", "content": dataPrompt} ], produceUserAnswer = True) @@ -817,7 +818,7 @@ class AgentAnalyst(AgentBase): try: # Get document content from AI - documentContent = await self.mydom.callAi([ + documentContent = await self.service.base.callAi([ {"role": "system", "content": f"You are a data analysis expert creating a {formatType} document."}, {"role": "user", "content": analysisPrompt} ], produceUserAnswer = True) diff --git a/modules/agents/agentCoach.py b/modules/agents/agentCoach.py index 13fd5dbb..e8a6ea54 100644 --- a/modules/agents/agentCoach.py +++ b/modules/agents/agentCoach.py @@ -32,8 +32,9 @@ class AgentCoach(AgentBase): "structuredOutput" ] - def setDependencies(self, mydom=None): + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" + self.setService(serviceBase) async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ @@ -52,7 +53,7 @@ class AgentCoach(AgentBase): outputSpecs = task.get("outputSpecifications", []) # Check AI service - if not self.mydom: + if not self.service or not self.service.base: return { "feedback": "The Coach agent requires an AI service to function.", "documents": [] @@ -171,7 +172,8 @@ class AgentCoach(AgentBase): """ try: - response = await self.mydom.callAi([ + # Get task understanding from AI + response = await self.service.base.callAi([ {"role": "system", "content": "You are a task analysis expert. Respond with valid JSON only."}, {"role": "user", "content": analysisPrompt} ]) @@ -254,7 +256,7 @@ class AgentCoach(AgentBase): systemPrompt = f"You create {outputFormat} format content based on requests and extracted data. Provide only the content in valid {outputFormat} format." # Generate content with AI - content = await self.mydom.callAi([ + content = await self.service.base.callAi([ {"role": "system", "content": systemPrompt}, {"role": "user", "content": generationPrompt} ]) diff --git a/modules/agents/agentCoder.py b/modules/agents/agentCoder.py index 798a744e..e0bf78bf 100644 --- a/modules/agents/agentCoder.py +++ b/modules/agents/agentCoder.py @@ -42,8 +42,9 @@ class AgentCoder(AgentBase): self.executionRetryLimit = int(APP_CONFIG.get("Agent_Coder_EXECUTION_RETRY")) # max retries self.tempDir = None - def setDependencies(self, mydom=None): + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" + self.setService(serviceBase) async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ @@ -64,7 +65,7 @@ class AgentCoder(AgentBase): outputSpecs = task.get("outputSpecifications", []) # Check if AI service is available - if not self.mydom: + if not self.service or not self.service.base: logger.error("No AI service configured for the Coder agent") return { "feedback": "The Coder agent is not properly configured.", @@ -373,7 +374,7 @@ Return ONLY Python code without explanations or markdown. ] try: - improvedContent = await self.mydom.callAi(messages, temperature=0.2) + improvedContent = await self.service.base.callAi(messages, temperature=0.2) # Extract code and requirements improvedCode = self._cleanCode(improvedContent) @@ -451,7 +452,7 @@ Only return valid JSON. Your entire response must be parseable as JSON. try: # Use a lower temperature for more deterministic response - response = await self.mydom.callAi(messages, produceUserAnswer = True, temperature=0.1) + response = await self.service.base.callAi(messages, produceUserAnswer = True, temperature=0.1) # Parse response as JSON if response: @@ -578,7 +579,7 @@ Return ONLY Python code without explanations or markdown. {"role": "user", "content": aiPrompt} ] - generatedContent = await self.mydom.callAi(messages, temperature=0.1) + generatedContent = await self.service.base.callAi(messages, temperature=0.1) # Extract code and requirements code = self._cleanCode(generatedContent) diff --git a/modules/agents/agentDocumentation.py b/modules/agents/agentDocumentation.py index 11ca8e1f..839c2856 100644 --- a/modules/agents/agentDocumentation.py +++ b/modules/agents/agentDocumentation.py @@ -30,8 +30,9 @@ class AgentDocumentation(AgentBase): "knowledge_organization" ] - def setDependencies(self, mydom=None): + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" + self.setService(serviceBase) async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ @@ -50,7 +51,7 @@ class AgentDocumentation(AgentBase): outputSpecs = task.get("outputSpecifications", []) # Check AI service - if not self.mydom: + if not self.service or not self.service.base: return { "feedback": "The Documentation agent requires an AI service to function.", "documents": [] @@ -204,7 +205,7 @@ class AgentDocumentation(AgentBase): """ try: - response = await self.mydom.callAi([ + response = await self.service.base.callAi([ {"role": "system", "content": "You are a documentation expert. Respond with valid JSON only."}, {"role": "user", "content": analysisPrompt} ]) @@ -374,7 +375,7 @@ This introduction should: The introduction should be professional and engaging, but short and precise, formatted according to {formatType} standards. do not add details, which are not requested by the Task Context. """ - introduction = await self.mydom.callAi([ + introduction = await self.service.base.callAi([ {"role": "system", "content": f"You are a documentation expert creating an introduction in {formatType} format."}, {"role": "user", "content": introPrompt} ], produceUserAnswer = True) @@ -400,7 +401,7 @@ The introduction should be professional and engaging, but short and precise, for Keep the summary focused and impactful, approximately 200-300 words. """ - executiveSummary = await self.mydom.callAi([ + executiveSummary = await self.service.base.callAi([ {"role": "system", "content": f"You are a documentation expert creating an executive summary in {formatType} format."}, {"role": "user", "content": summaryPrompt} ], produceUserAnswer = True) @@ -450,7 +451,7 @@ This section should: Be thorough in your coverage of this section, providing substantive content focussing on the Task content. """ - sectionContent = await self.mydom.callAi([ + sectionContent = await self.service.base.callAi([ {"role": "system", "content": f"You are a documentation expert creating detailed content for the {sectionTitle} section."}, {"role": "user", "content": sectionPrompt} ], produceUserAnswer = True) @@ -477,7 +478,7 @@ Be thorough in your coverage of this section, providing substantive content focu The conclusion should be professional and impactful, formatted according to {formatType} standards. """ - conclusion = await self.mydom.callAi([ + conclusion = await self.service.base.callAi([ {"role": "system", "content": f"You are a documentation expert creating a conclusion in {formatType} format."}, {"role": "user", "content": conclusionPrompt} ], produceUserAnswer = True) diff --git a/modules/agents/agentEmail.py b/modules/agents/agentEmail.py index 03fb9018..b5e161f6 100644 --- a/modules/agents/agentEmail.py +++ b/modules/agents/agentEmail.py @@ -1,83 +1,50 @@ """ -Email agent for generating and sending emails. -Provides email template generation and sending capabilities. +Email Agent Module. +Handles email-related tasks using Microsoft Graph API. """ import logging -from typing import Dict, Any, List, Tuple import json -import os -import requests -import base64 -from datetime import datetime -import re -from bs4 import BeautifulSoup -import msal -from modules.shared.configuration import APP_CONFIG - -from modules.workflow.agentBase import AgentBase +from typing import Dict, Any, List, Optional +from ..workflow.agentBase import AgentBase logger = logging.getLogger(__name__) class AgentEmail(AgentBase): - """AI-driven agent for creating email templates and drafts using Microsoft Graph API""" + """Agent for handling email-related tasks.""" def __init__(self): - """Initialize the email agent""" + """Initialize the email agent.""" super().__init__() self.name = "email" - self.label = "Email Templates" - self.description = "Creates email templates with HTML-formatted body and attachments from input documents" + self.label = "Email Agent" + self.description = "Handles email composition and sending using Microsoft Graph API" self.capabilities = [ - "emailDrafting", - "contentFormatting", - "htmlTemplates", - "documentAttachment", - "msftGraphIntegration" + "email_composition", + "email_draft_creation", + "email_template_generation" ] - - # Initialize configuration - self.client_id = None - self.client_secret = None - self.tenant_id = None - self.redirect_uri = None - self.authority = None - self.scopes = ["Mail.ReadWrite", "User.Read"] - - # API base URL for Microsoft authentication - self.api_base_url = APP_CONFIG.get("APP_API_URL", "(no-url)") - - def setDependencies(self, mydom=None): + self.serviceBase = None + + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" - self._loadConfiguration() - - def _loadConfiguration(self): - """Load Microsoft Graph API configuration from config files""" - try: - self.client_id = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_ID") - self.client_secret = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_SECRET") - self.tenant_id = APP_CONFIG.get("Agent_Mail_MSFT_TENANT_ID", "common") - self.redirect_uri = APP_CONFIG.get("Agent_Mail_MSFT_REDIRECT_URI") - - # Set authority URL - self.authority = f"https://login.microsoftonline.com/{self.tenant_id}" - - logger.info(f"Email agent initialized with tenant ID: {self.tenant_id}") - logger.info(f"Redirect URI: {self.redirect_uri}") - - except Exception as e: - logger.error(f"Error loading Microsoft Graph configuration: {str(e)}") - + self.serviceBase = serviceBase + async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ - Process a task by creating an email template based on input documents. - Sends a login request to the frontend if Microsoft authentication is required. + Process an email-related task. Args: - task: Task dictionary with prompt, inputDocuments, outputSpecifications - + task: Task object containing: + - prompt: Instructions for the agent + - inputDocuments: List of documents to process + - outputSpecifications: List of required output documents + - context: Additional context including workflow info + Returns: - Dictionary with feedback and documents + Dictionary containing: + - feedback: Text response explaining what was done + - documents: List of created documents """ try: # Extract task information @@ -86,29 +53,27 @@ class AgentEmail(AgentBase): outputSpecs = task.get("outputSpecifications", []) # Check AI service - if not self.mydom: + if not self.service.base: return { "feedback": "The Email agent requires an AI service to function.", "documents": [] } - # Check Microsoft authentication status - user_info, access_token = self._getCurrentUserToken() - - # If not authenticated, trigger frontend authentication flow - if not user_info or not access_token: - # Create authentication instruction document - auth_instructions = self._createFrontendAuthTriggerDocument() - - # Return feedback with authentication trigger log for frontend + # Check if Microsoft connector is available + if not hasattr(self.service, 'msft'): return { - "feedback": "⚠️ Microsoft authentication required. Please complete the authentication process when prompted.", - "documents": [auth_instructions], - "log": { - "message": "doMsftLogin", - "type": "system", - "details": "Microsoft authentication required to create email drafts" - } + "feedback": "Microsoft connector not available. Please ensure Microsoft integration is properly configured.", + "documents": [] + } + + # Get Microsoft token + token_data = self.service.msft.getMsftToken() + if not token_data: + # Create authentication trigger document + auth_doc = self._createFrontendAuthTriggerDocument() + return { + "feedback": "Microsoft authentication required. Please authenticate to continue.", + "documents": [auth_doc] } # Extract document data from input @@ -121,7 +86,7 @@ class AgentEmail(AgentBase): htmlPreview = self._createHtmlPreview(emailTemplate) # Attempt to create a draft email using Microsoft Graph API - draft_result, user_email = self._createDraftEmail( + draft_result = self.service.msft.createDraftEmail( emailTemplate["recipient"], emailTemplate["subject"], emailTemplate["htmlBody"], @@ -164,7 +129,7 @@ class AgentEmail(AgentBase): # Prepare feedback message if draft_result: - feedback = f"Email draft created successfully for {user_email}. The subject is: '{emailTemplate['subject']}'" + feedback = f"Email draft created successfully for {emailTemplate.get('recipient')}. The subject is: '{emailTemplate['subject']}'" if attachments: feedback += f" with {len(attachments)} attachment(s)" feedback += ". You can open and edit it in your Outlook draft folder." @@ -175,63 +140,39 @@ class AgentEmail(AgentBase): "feedback": feedback, "documents": documents } - + except Exception as e: - logger.error(f"Error in email creation: {str(e)}", exc_info=True) + logger.error(f"Error in email agent: {str(e)}") return { - "feedback": f"Error creating email template: {str(e)}", + "feedback": f"Error processing email task: {str(e)}", "documents": [] } def _createFrontendAuthTriggerDocument(self) -> Dict[str, Any]: - """ - Create a simple document that explains authentication is required. - This document is minimal as the actual authentication will be handled by frontend. - - Returns: - Document dictionary - """ - html_content = """ - - - - - Microsoft Authentication Required - - - -
-

Microsoft Authentication Required

- -

To create email templates and drafts, you need to authenticate with your Microsoft account.

- -

The application will now initiate the Microsoft authentication process. Please follow the instructions in the authentication window.

- -
-

Note: You only need to authenticate once. Your session will be remembered for future email operations.

-
+ """Create a document that triggers Microsoft authentication in the frontend.""" + return { + "name": "microsoft_auth", + "ext": "html", + "mimeType": "text/html", + "data": """ +
+

Microsoft Authentication Required

+

Please click the button below to authenticate with Microsoft:

+
- - - """ - - return self.formatAgentDocumentOutput( - "microsoft_authentication.html", - html_content, - "text/html" - ) - - def _processInputDocuments(self, documents: List[Dict[str, Any]]) -> tuple: + """, + "base64Encoded": False, + "metadata": { + "isText": True + } + } + + def _processInputDocuments(self, input_docs: List[Dict[str, Any]]) -> tuple: """ Process input documents to extract content and prepare attachments. Args: - documents: List of input documents + input_docs: List of input documents Returns: Tuple of (document content text, list of attachments) @@ -239,7 +180,7 @@ class AgentEmail(AgentBase): documentContents = [] attachments = [] - for doc in documents: + for doc in input_docs: docName = doc.get("name", "unnamed") if doc.get("ext"): docName = f"{docName}.{doc.get('ext')}" @@ -299,10 +240,10 @@ class AgentEmail(AgentBase): """ try: - response = await self.mydom.callAi([ + response = await self.service.base.callAi([ {"role": "system", "content": "You are an email template specialist. Create professional emails. Respond with valid JSON only."}, {"role": "user", "content": emailPrompt} - ], produceUserAnswer=True) + ]) # Extract JSON from response jsonStart = response.find('{') @@ -320,7 +261,7 @@ class AgentEmail(AgentBase): "plainBody": f"This email is regarding your request: {prompt}", "htmlBody": f"

This email is regarding your request: {prompt}

" } - + except Exception as e: logger.warning(f"Error generating email template: {str(e)}") return { @@ -384,260 +325,6 @@ class AgentEmail(AgentBase): """ return html - def _getCurrentUserToken(self) -> tuple: - """ - Get the current user's Microsoft token using the current user context. - Returns tuple of (user_info, access_token) or (None, None) if not authenticated. - """ - try: - if not self.mydom: - logger.error("No mydom interface available") - return None, None - - # Get token data from database using LucyDOMInterface - token_data = self.mydom.getMsftToken() - if not token_data: - logger.info("No Microsoft token found for user") - return None, None - - # Verify token is still valid - if not self._verifyToken(token_data.get("access_token")): - logger.info("Token invalid, attempting refresh") - if not self._refreshToken(token_data): - logger.info("Token refresh failed") - return None, None - # Get updated token data after refresh - token_data = self.mydom.getMsftToken() - - # Get user info from token data - user_info = token_data.get("user_info") - if not user_info: - # If user_info is not in token_data, try to get it from the token - headers = { - 'Authorization': f'Bearer {token_data.get("access_token", "")}', - 'Content-Type': 'application/json' - } - try: - response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) - if response.status_code == 200: - user_data = response.json() - user_info = { - "name": user_data.get("displayName", ""), - "email": user_data.get("userPrincipalName", ""), - "id": user_data.get("id", "") - } - # Update token data with user info - token_data["user_info"] = user_info - self.mydom.saveMsftToken(token_data) - logger.info(f"Retrieved and stored user info for {user_info.get('name', 'Unknown User')}") - else: - logger.warning(f"Failed to get user info: {response.status_code} - {response.text}") - return None, None - except Exception as e: - logger.error(f"Error getting user info: {str(e)}") - return None, None - - logger.info(f"Retrieved user info for {user_info.get('name', 'Unknown User')}") - return user_info, token_data.get("access_token") - - except Exception as e: - logger.error(f"Error getting current user token: {str(e)}") - return None, None - - def _verifyToken(self, token: str) -> bool: - """Verify the access token is valid""" - try: - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) - return response.status_code == 200 - - except Exception as e: - logger.error(f"Error verifying token: {str(e)}") - return False - - def _refreshToken(self, token_data: Dict[str, Any]) -> bool: - """Refresh the access token using the stored refresh token""" - try: - if not token_data or not token_data.get("refresh_token"): - logger.warning("No refresh token available") - return False - - msal_app = msal.ConfidentialClientApplication( - self.client_id, - authority=self.authority, - client_credential=self.client_secret - ) - - result = msal_app.acquire_token_by_refresh_token( - token_data["refresh_token"], - scopes=self.scopes - ) - - if "error" in result: - logger.error(f"Error refreshing token: {result.get('error')}") - return False - - # Update token data - token_data["access_token"] = result["access_token"] - if "refresh_token" in result: - token_data["refresh_token"] = result["refresh_token"] - - # Save updated token - self.mydom.saveMsftToken(token_data) - logger.info("Access token refreshed successfully") - return True - - except Exception as e: - logger.error(f"Error refreshing token: {str(e)}") - return False - - def _createDraftEmail(self, recipient, subject, body, attachments=None): - """Create a draft email using Microsoft Graph API""" - try: - # Get current user token - user_info, access_token = self._getCurrentUserToken() - - if not user_info or not access_token: - logger.warning("No authenticated user found, cannot create draft email") - return False, None - - # Create draft email using Graph API - email_result = self._createGraphDraftEmail(access_token, recipient, subject, body, attachments) - - if email_result: - return True, user_info.get("email") - else: - return False, user_info.get("email") - - except Exception as e: - logger.error(f"Error in creating draft email: {str(e)}") - return False, None - - def _createGraphDraftEmail(self, access_token, recipient, subject, body, attachments=None): - """ - Create a draft email using Microsoft Graph API. - Treats all files as binary attachments without content analysis. - - Args: - access_token: Microsoft Graph access token - recipient: Email recipient - subject: Email subject - body: HTML body of the email - attachments: List of attachments - - Returns: - Draft result or None if failed - """ - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - - # Prepare email data with proper structure - email_data = { - 'subject': subject, - 'body': { - 'contentType': 'HTML', - 'content': body - }, - 'toRecipients': [ - { - 'emailAddress': { - 'address': recipient - } - } - ] - } - - # Add attachments if available - if attachments and len(attachments) > 0: - email_data['attachments'] = [] - - for attachment in attachments: - doc = attachment.get('document', {}) - file_name = attachment.get('name', 'attachment.file') - - logger.info(f"Processing attachment: {file_name}") - - # Get the document data directly - file_content = doc.get('data') - if not file_content: - logger.warning(f"No data found for attachment: {file_name}") - continue - - # Get content type from document metadata - mime_type = doc.get('mimeType', 'application/octet-stream') - is_base64 = doc.get('base64Encoded', False) - - # Handle content encoding - try: - if is_base64: - # Content is already base64 encoded - content_bytes = file_content - else: - # Content needs to be base64 encoded - if isinstance(file_content, str): - # For text files, encode the string to bytes first - content_bytes = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') - elif isinstance(file_content, bytes): - # For binary files, encode directly - content_bytes = base64.b64encode(file_content).decode('utf-8') - else: - logger.warning(f"Unexpected content type for {file_name}") - continue - - # Calculate size from decoded content - decoded_size = len(base64.b64decode(content_bytes)) - - # Add attachment to email data - logger.info(f"Adding attachment: {file_name} ({mime_type}, size: {decoded_size} bytes)") - attachment_data = { - '@odata.type': '#microsoft.graph.fileAttachment', - 'name': file_name, - 'contentType': mime_type, - 'contentBytes': content_bytes, - 'isInline': False, - 'size': decoded_size - } - email_data['attachments'].append(attachment_data) - logger.info(f"Successfully added attachment: {file_name}") - - except Exception as e: - logger.error(f"Error processing attachment {file_name}: {str(e)}") - continue - - # Try to create draft using drafts folder endpoint - try: - logger.info("Attempting to create draft email using messages endpoint") - logger.info(f"Email data structure: subject={subject}, recipient={recipient}, " + - f"has_attachments={bool(email_data.get('attachments'))}, " + - f"attachment_count={len(email_data.get('attachments', []))}") - - # Create the draft message - response = requests.post( - 'https://graph.microsoft.com/v1.0/me/messages', - headers=headers, - json=email_data - ) - - if response.status_code >= 200 and response.status_code < 300: - logger.info("Successfully created draft email using messages endpoint") - return response.json() - else: - logger.error(f"Messages endpoint method failed: {response.status_code} - {response.text}") - logger.error(f"Request headers: {headers}") - logger.error(f"Request body: {json.dumps(email_data, indent=2)}") - return None - - except Exception as e: - logger.error(f"Exception creating draft email: {str(e)}", exc_info=True) - return None - -# Factory function for the Email agent -def getAgentEmail(): - """Returns an instance of the Email agent.""" +def getAgentEmail() -> AgentEmail: + """Factory function to create and return an EmailAgent instance.""" return AgentEmail() \ No newline at end of file diff --git a/modules/agents/agentWebcrawler.py b/modules/agents/agentWebcrawler.py index 7ea7c9c5..1e703c3a 100644 --- a/modules/agents/agentWebcrawler.py +++ b/modules/agents/agentWebcrawler.py @@ -7,6 +7,7 @@ import logging import json import re import time +import os from typing import Dict, Any, List from urllib.parse import quote_plus, unquote @@ -23,17 +24,17 @@ class AgentWebcrawler(AgentBase): """AI-driven agent for web research and information retrieval""" def __init__(self): - """Initialize the webcrawler agent""" + """Initialize the web crawler agent""" super().__init__() self.name = "webcrawler" - self.label = "Web-Research" - self.description = "Conducts web research and collects information from online sources" + self.label = "Web Crawler" + self.description = "Gathers and analyzes web content using AI with multi-step research" self.capabilities = [ - "webSearch", - "informationRetrieval", - "dataCollection", - "searchResultsAnalysis", - "webpageContentExtraction" + "web_research", + "content_gathering", + "data_extraction", + "information_synthesis", + "source_verification" ] # Web crawling configuration @@ -45,13 +46,13 @@ class AgentWebcrawler(AgentBase): self.maxResults = int(APP_CONFIG.get("Agent_Webcrawler_SERPAPI_MAX_SEARCH_RESULTS", "5")) self.timeout = int(APP_CONFIG.get("Agent_Webcrawler_SERPAPI_TIMEOUT", "30")) self.userAgent = APP_CONFIG.get("Agent_Webcrawler_SERPAPI_USER_AGENT", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - + if not self.srcApikey: logger.error("SerpAPI key not configured") - - def setDependencies(self, mydom=None): + def setDependencies(self, serviceBase=None): """Set external dependencies for the agent.""" + self.setService(serviceBase) async def processTask(self, task: Dict[str, Any]) -> Dict[str, Any]: """ @@ -66,13 +67,14 @@ class AgentWebcrawler(AgentBase): try: # Extract task information prompt = task.get("prompt", "") + inputDocuments = task.get("inputDocuments", []) outputSpecs = task.get("outputSpecifications", []) workflow = task.get("context", {}).get("workflow", {}) # Check AI service - if not self.mydom: + if not self.service or not self.service.base: return { - "feedback": "The Webcrawler agent requires an AI service to function effectively.", + "feedback": "The Web Crawler agent requires an AI service to function.", "documents": [] } @@ -147,10 +149,10 @@ class AgentWebcrawler(AgentBase): try: # Get research plan from AI - response = await self.mydom.callAi([ - {"role": "system", "content": "You are a web research planning expert. Create precise research plans. Respond with valid JSON only."}, + response = await self.service.base.callAi([ + {"role": "system", "content": "You are a research expert. Respond with valid JSON only."}, {"role": "user", "content": researchPrompt} - ], produceUserAnswer=True) + ]) # Extract JSON jsonStart = response.find('{') @@ -316,10 +318,10 @@ class AgentWebcrawler(AgentBase): """ # Get summary from AI - summary = await self.mydom.callAi([ - {"role": "system", "content": "You are a web content summarization expert. Create concise summaries."}, + summary = await self.service.base.callAi([ + {"role": "system", "content": "You are a research expert. Respond with valid JSON only."}, {"role": "user", "content": summaryPrompt} - ], produceUserAnswer=True) + ]) # Add summary to result result["summary"] = summary.strip() @@ -465,8 +467,8 @@ class AgentWebcrawler(AgentBase): try: # Generate report with AI - reportContent = await self.mydom.callAi([ - {"role": "system", "content": f"You create professional research reports in {templateFormat} format."}, + reportContent = await self.service.base.callAi([ + {"role": "system", "content": "You are a research expert. Respond with valid JSON only."}, {"role": "user", "content": reportPrompt} ]) @@ -616,10 +618,10 @@ class AgentWebcrawler(AgentBase): if not self.srcApikey: return [] - # Get user language from mydom if available + # Get user language from serviceBase if available userLanguage = "en" # Default language - if self.mydom.userLanguage: - userLanguage = self.mydom.userLanguage + if self.service.base.userLanguage: + userLanguage = self.service.base.userLanguage try: # Format the search request for SerpAPI diff --git a/modules/connectors/connectorDbJson.py b/modules/connectors/connectorDbJson.py index fa23fb24..93f06e42 100644 --- a/modules/connectors/connectorDbJson.py +++ b/modules/connectors/connectorDbJson.py @@ -562,7 +562,7 @@ class DatabaseConnector: """Returns the initial ID for a table.""" systemData = self._loadSystemTable() initialId = systemData.get(table) - logger.debug(f"Database '{self.dbDatabase}': Initial ID for table '{table}' is {initialId}") + logger.debug(f"Database '{self.dbDatabase}': Table: {systemData}, Initial ID for table '{table}' is {initialId}") if initialId is None: logger.debug(f"No initial ID found for table {table}") return initialId diff --git a/modules/interfaces/gatewayAccess.py b/modules/interfaces/gatewayAccess.py index 95e27a2e..e4aa78b1 100644 --- a/modules/interfaces/gatewayAccess.py +++ b/modules/interfaces/gatewayAccess.py @@ -1,111 +1,120 @@ """ -Access control functions for the Gateway system. -Manages user access and permissions. +Access control module for Gateway interface. +Handles user access management and permission checks. """ 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]]: +class GatewayAccess: """ - Unified user access management function that filters data based on user privileges - and adds access control attributes. - - Args: - 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 - db: Database connector instance - - Returns: - Filtered recordset with access control attributes + Access control class for Gateway interface. + Handles user access management and permission checks. """ - userPrivilege = currentUser.get("privilege", "user") - filtered_records = [] - # Apply filtering based on privilege - if userPrivilege == "sysadmin": - 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] - 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] - - # Add access control attributes to each record - for record in filtered_records: - record_id = record.get("id") + def __init__(self, currentUser: Dict[str, Any], db): + """Initialize with user context.""" + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") - # 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) - 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) - 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) - - return filtered_records + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + self.db = db -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. - - Args: - 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 - db: Database connector instance - - Returns: - Boolean indicating permission - """ - userPrivilege = currentUser.get("privilege", "user") - - # System admins can modify anything - if userPrivilege == "sysadmin": - return True + def uam(self, table: str, 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. - # Check specific record permissions - if recordId is not None: - # Get the record to check ownership - records = db.getRecordset(table, recordFilter={"id": recordId}) - if not records: - return False + Args: + table: Name of the table + recordset: Recordset to filter based on access rules - record = records[0] + Returns: + Filtered recordset with access control attributes + """ + userPrivilege = self.currentUser.get("privilege", "user") + filtered_records = [] - # Admins can modify anything in their mandate - 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": + # Apply filtering based on privilege + if userPrivilege == "sysadmin": + 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] + else: # Regular users + # Users only see records they own within their mandate + filtered_records = [r for r in recordset + if r.get("_mandateId") == self._mandateId and r.get("_userId") == self._userId] + + # Add access control attributes to each record + for record in filtered_records: + record_id = record.get("id") + + # Set access control flags based on user permissions + if table == "mandates": + record["_hideView"] = False # Everyone can view + record["_hideEdit"] = not self.canModify("mandates", record_id) + record["_hideDelete"] = not self.canModify("mandates", record_id) + elif table == "users": + record["_hideView"] = False # Everyone can view + record["_hideEdit"] = not self.canModify("users", record_id) + record["_hideDelete"] = not self.canModify("users", record_id) + else: + # Default access control for other tables + record["_hideView"] = False + record["_hideEdit"] = not self.canModify(table, record_id) + record["_hideDelete"] = not self.canModify(table, record_id) + + return filtered_records + + def canModify(self, table: str, recordId: Optional[int] = None) -> bool: + """ + Checks if the current user can modify (create/update/delete) records in a table. + + Args: + table: Name of the table + recordId: Optional record ID for specific record check + + Returns: + Boolean indicating permission + """ + userPrivilege = self.currentUser.get("privilege", "user") + + # System admins can modify anything + if userPrivilege == "sysadmin": + return True + + # Check specific record permissions + if recordId is not None: + # Get the record to check ownership + records = self.db.getRecordset(table, recordFilter={"id": recordId}) + if not records: return False - return True + + record = records[0] - # Users can only modify their own records - if (record.get("_mandateId") == _mandateId and - record.get("_userId") == _userId): - return True - - return False - else: - # For general table modify permission (e.g., create) - # Admins can create anything in their mandate - if userPrivilege == "admin": - return True - - # Regular users can create most entities - if table == "mandates": - return False # Regular users can't create mandates - return True \ No newline at end of file + # Admins can modify anything in their mandate + if userPrivilege == "admin" and record.get("_mandateId") == self._mandateId: + # Exception: Can't modify Root mandate unless you are a sysadmin + if table == "mandates" and record.get("initialid") and userPrivilege != "sysadmin": + return False + return True + + # Users can only modify their own records + if (record.get("_mandateId") == self._mandateId and + record.get("_userId") == self._userId): + return True + + return False + else: + # For general table modify permission (e.g., create) + # Admins can create anything in their mandate + if userPrivilege == "admin": + return True + + # Regular users can create most entities + if table == "mandates": + return False # Regular users can't create mandates + return True \ No newline at end of file diff --git a/modules/interfaces/gatewayInterface.py b/modules/interfaces/gatewayInterface.py index 6d259393..d64337df 100644 --- a/modules/interfaces/gatewayInterface.py +++ b/modules/interfaces/gatewayInterface.py @@ -3,18 +3,24 @@ Interface to the Gateway system. Manages users and mandates for authentication. """ +from datetime import datetime import os import logging from typing import Dict, Any, List, Optional, Union import importlib +import json from passlib.context import CryptContext from modules.connectors.connectorDbJson import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.interfaces.gatewayAccess import _uam, _canModify +from modules.interfaces.gatewayAccess import GatewayAccess logger = logging.getLogger(__name__) + +# Singleton factory for GatewayInterface instances per context +_gatewayInterfaces = {} + # Password-Hashing pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") @@ -25,40 +31,88 @@ class GatewayInterface: Manages users and mandates. """ - 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 + def __init__(self, currentUser: Dict[str, Any]): + """Initializes the Gateway Interface with user context.""" + + # Ensure valid DB + + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + if not self._mandateId or not self._userId: + raise ValueError("Invalid initial context: _mandateId and id are required") + # Initialize database self._initializeDatabase() - - # Load user information - self.currentUser = self._getCurrentUserInfo() - + # Initialize standard records if needed self._initRecords() + + # Set user context + + if currentUser.get("id") == "-1": + logger.debug(f"Initializing GatewayInterface with Root User") + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + self._initializeDatabase() + + mandateId = self.getInitialId("mandates") + userId = self.getInitialId("users") + currentUser = { + "_mandateId": mandateId, + "id": userId + } + logger.debug(f"Initializing GatewayInterface with rootUser={currentUser}") + else: + logger.debug(f"Initializing GatewayInterface with currentUser={currentUser}") + + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + # Add language settings + self.userLanguage = currentUser.get("language", "en") # Default user language + + # Initialize database + self._initializeDatabase() + + # Initialize access control + self.access = GatewayAccess(self.currentUser, self.db) + 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=dbHost, - dbDatabase=dbDatabase, - dbUser=dbUser, - dbPassword=dbPassword, - _mandateId=self._mandateId, - _userId=self._userId - ) + try: + # 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=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + _mandateId=self._mandateId, + _userId=self._userId + ) + + # Set context + self.db.updateContext(self._mandateId, self._userId) + + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize database: {str(e)}") + raise def _getCurrentUserInfo(self) -> Optional[Dict[str, Any]]: """Returns information about the current user.""" @@ -72,9 +126,13 @@ class GatewayInterface: def _initRecords(self): """Initializes standard records in the database if they don't exist.""" + self._initRootMandate() + # Update database context with new IDs + if self._mandateId and self._userId: + self.db.updateContext(self._mandateId, self._userId) + self._initAdminUser() - # Update database context with new IDs if self._mandateId and self._userId: self.db.updateContext(self._mandateId, self._userId) @@ -90,7 +148,7 @@ class GatewayInterface: logger.info("Creating Root mandate") rootMandate = { "name": "Root", - "language": "de" + "language": "en" } createdMandate = self.db.recordCreate("mandates", rootMandate) logger.info(f"Root mandate created with ID {createdMandate['id']}") @@ -113,7 +171,7 @@ class GatewayInterface: "email": "admin@example.com", "fullName": "Administrator", "disabled": False, - "language": "de", + "language": "en", "privilege": "sysadmin", "authenticationAuthority": "local", "hashedPassword": self._getPasswordHash("The 1st Poweron Admin") # Use a secure password in production! @@ -139,7 +197,7 @@ class GatewayInterface: Returns: Filtered recordset with access control attributes """ - return _uam(self.currentUser, table, recordset, self._mandateId, self._userId, self.db) + return self.access.uam(table, recordset) def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """ @@ -152,12 +210,12 @@ class GatewayInterface: Returns: Boolean indicating permission """ - return _canModify(self.currentUser, table, recordId, self._mandateId, self._userId, self.db) + return self.access.canModify(table, recordId) def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(table) - + def _getPasswordHash(self, password: str) -> str: """Creates a hash for a password.""" return pwdContext.hash(password) @@ -187,7 +245,7 @@ class GatewayInterface: filteredMandates = self._uam("mandates", mandates) return filteredMandates[0] if filteredMandates else None - def createMandate(self, name: str, language: str = "de") -> Dict[str, Any]: + def createMandate(self, name: str, language: str = "en") -> Dict[str, Any]: """Creates a new mandate if user has permission.""" if not self._canModify("mandates"): raise PermissionError("No permission to create mandates") @@ -322,7 +380,7 @@ class GatewayInterface: return user def createUser(self, username: str, password: str = None, email: str = None, fullName: str = None, - language: str = "de", _mandateId: int = None, disabled: bool = False, + language: str = "en", _mandateId: int = None, disabled: bool = False, privilege: str = "user", authenticationAuthority: str = "local") -> Dict[str, Any]: """Create a new user""" try: @@ -420,8 +478,7 @@ class GatewayInterface: if "hashedPassword" in authenticatedUser: del authenticatedUser["hashedPassword"] - return authenticatedUser - + return authenticatedUser def updateUser(self, _userId: str, userData: Dict[str, Any]) -> Dict[str, Any]: """Updates a user if current user has permission.""" @@ -512,20 +569,81 @@ class GatewayInterface: return success + # Microsoft Login + + def getMsftToken(self) -> Optional[Dict[str, Any]]: + """Get Microsoft token data for the current user from database""" + try: + # Get token from database using current user's mandateId and userId + tokens = self.db.getRecordset("msftTokens", recordFilter={ + "_mandateId": self._mandateId, + "_userId": self._userId + }) + + if tokens and len(tokens) > 0: + token_data = json.loads(tokens[0]["token_data"]) + logger.debug(f"Retrieved Microsoft token for user {self._userId}") + return token_data + else: + logger.debug(f"No Microsoft token found for user {self._userId}") + return None + + except Exception as e: + logger.error(f"Error retrieving Microsoft token: {str(e)}") + return None + + def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: + """Save Microsoft token data for the current user to database""" + try: + # Check if token already exists + tokens = self.db.getRecordset("msftTokens", recordFilter={ + "_mandateId": self._mandateId, + "_userId": self._userId + }) + + if tokens and len(tokens) > 0: + # Update existing token + token_id = tokens[0]["id"] + updated_data = { + "token_data": json.dumps(token_data), + "updated_at": datetime.now().isoformat() + } + self.db.recordModify("msftTokens", token_id, updated_data) + logger.debug(f"Updated Microsoft token for user {self._userId}") + else: + # Create new token with UUID + new_token = { + "_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.debug(f"Saved new Microsoft token for user {self._userId}") + + return True + + except Exception as e: + logger.error(f"Error saving Microsoft token: {str(e)}") + return False -# Singleton factory for GatewayInterface instances per context -_gatewayInterfaces = {} -def getGatewayInterface(_mandateId: str = None, _userId: str = None) -> GatewayInterface: +def getInterface(currentUser: Dict[str, Any]) -> 'GatewayInterface': """ - Returns a GatewayInterface instance for the specified context. - Reuses existing instances. + Returns a GatewayInterface instance for the current user. + Handles initialization of database and records. """ - # For initialization, use empty strings instead of None - contextKey = f"{_mandateId or ''}_{_userId or ''}" + mandateId = currentUser.get("_mandateId") + userId = currentUser.get("id") + if not mandateId or not userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + # Create context key + contextKey = f"{mandateId}_{userId}" + + # Create new instance if not exists if contextKey not in _gatewayInterfaces: - _gatewayInterfaces[contextKey] = GatewayInterface(_mandateId or '', _userId or '') + _gatewayInterfaces[contextKey] = GatewayInterface(currentUser) + return _gatewayInterfaces[contextKey] - -# 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 bde39c60..12d3118c 100644 --- a/modules/interfaces/gatewayModel.py +++ b/modules/interfaces/gatewayModel.py @@ -6,6 +6,12 @@ from typing import List, Dict, Any, Optional from datetime import datetime import uuid +# Get all attributes of the model +def getModelAttributes(modelClass): + return [attr for attr in dir(modelClass) + if not callable(getattr(modelClass, attr)) + and not attr.startswith('_') + and attr not in ('metadata', 'query', 'query_class', 'label', 'field_labels')] class Label(BaseModel): """Label for an attribute or a class with support for multiple languages""" diff --git a/modules/interfaces/lucydomAccess.py b/modules/interfaces/lucydomAccess.py index 0db95a88..b2e4423b 100644 --- a/modules/interfaces/lucydomAccess.py +++ b/modules/interfaces/lucydomAccess.py @@ -5,19 +5,24 @@ Handles user access management and permission checks. from typing import Dict, Any, List, Optional -class LucyDOMAccess: +class LucydomAccess: """ Access control class for LucyDOM interface. 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], db): """Initialize with user context.""" self.currentUser = currentUser - self._mandateId = _mandateId - self._userId = _userId + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + self.db = db - def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def uam(self, table: str, 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. @@ -30,6 +35,7 @@ class LucyDOMAccess: Filtered recordset with access control attributes """ userPrivilege = self.currentUser.get("privilege", "user") + print("DEBUG: User privilege:", userPrivilege, self.currentUser.get("username"),self.currentUser.get("email")) filtered_records = [] # Apply filtering based on privilege @@ -54,40 +60,34 @@ class LucyDOMAccess: # Set access control flags based on user permissions if table == "prompts": record["_hideView"] = False # Everyone can view - # 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 + record["_hideEdit"] = not self.canModify("prompts", record_id) + record["_hideDelete"] = not self.canModify("prompts", record_id) elif table == "files": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not self._canModify("files", record_id) - record["_hideDelete"] = not self._canModify("files", record_id) - record["_hideDownload"] = not self._canModify("files", record_id) + record["_hideEdit"] = not self.canModify("files", record_id) + record["_hideDelete"] = not self.canModify("files", record_id) + record["_hideDownload"] = not self.canModify("files", record_id) elif table == "workflows": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not self._canModify("workflows", record_id) - record["_hideDelete"] = not self._canModify("workflows", record_id) + record["_hideEdit"] = not self.canModify("workflows", record_id) + record["_hideDelete"] = not self.canModify("workflows", record_id) elif table == "workflowMessages": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not self._canModify("workflows", record.get("workflowId")) - record["_hideDelete"] = not self._canModify("workflows", record.get("workflowId")) + record["_hideEdit"] = not self.canModify("workflows", record.get("workflowId")) + record["_hideDelete"] = not self.canModify("workflows", record.get("workflowId")) elif table == "workflowLogs": record["_hideView"] = False # Everyone can view - record["_hideEdit"] = not self._canModify("workflows", record.get("workflowId")) - record["_hideDelete"] = not self._canModify("workflows", record.get("workflowId")) + record["_hideEdit"] = not self.canModify("workflows", record.get("workflowId")) + record["_hideDelete"] = not self.canModify("workflows", record.get("workflowId")) else: # Default access control for other tables record["_hideView"] = False - record["_hideEdit"] = not self._canModify(table, record_id) - record["_hideDelete"] = not self._canModify(table, record_id) + record["_hideEdit"] = not self.canModify(table, record_id) + record["_hideDelete"] = not self.canModify(table, record_id) return filtered_records - def _canModify(self, table: str, recordId: Optional[int] = None) -> bool: + def canModify(self, table: str, recordId: Optional[int] = None) -> bool: """ Checks if the current user can modify (create/update/delete) records in a table. diff --git a/modules/interfaces/lucydomInterface.py b/modules/interfaces/lucydomInterface.py index 0b745ab3..b90ef652 100644 --- a/modules/interfaces/lucydomInterface.py +++ b/modules/interfaces/lucydomInterface.py @@ -9,12 +9,10 @@ import uuid from datetime import datetime from typing import Dict, Any, List, Optional, Union -import importlib import hashlib -import json -from modules.shared.mimeUtils import isTextMimeType, determineContentEncoding -from modules.interfaces.lucydomAccess import LucyDOMAccess +from modules.shared.mimeUtils import isTextMimeType +from modules.interfaces.lucydomAccess import LucydomAccess # DYNAMIC PART: Connectors to the Interface from modules.connectors.connectorDbJson import DatabaseConnector @@ -24,23 +22,8 @@ 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() +# Singleton factory for Lucydom instances with AI service per context +_lucydomInterfaces = {} # Custom exceptions for file handling class FileError(Exception): @@ -63,85 +46,84 @@ class FileDeletionError(FileError): """Exception raised when there's an error deleting a file.""" pass -from modules.security.auth import getInitialContext - -class LucyDOMInterface: +class LucydomInterface: """ Interface to the LucyDOM database. Uses the JSON connector for data access. """ - def __init__(self, _mandateId: str, _userId: str): - """Initializes the LucyDOM Interface with mandate and user context.""" - logger.debug(f"Initializing LucyDOMInterface with mandateId={_mandateId}, userId={_userId}") - self._mandateId = _mandateId - self._userId = _userId + def __init__(self, currentUser: Dict[str, Any]): + """Initializes the LucyDOM Interface with user context.""" + logger.debug(f"Initializing LucydomInterface with currentUser={currentUser}") + + # Ensure valid user context + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") # Add language settings - self.userLanguage = "en" # Default user language + self.userLanguage = currentUser.get("language", "en") # Default user language - # 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 + # Initialize database self._initializeDatabase() - - # Load user information - self.currentUser = self._getCurrentUserInfo() + + # Initialize standard records + self._initRecords() + + # Initialize AI service + self.aiService = ChatService() + if not self.aiService: + logger.warning("AI service not available during LucydomInterface initialization") # Initialize access control - self.access = LucyDOMAccess(self.currentUser, self._mandateId, self._userId) - self.access.db = self.db # Share database connection - - # 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]: - """Gets information about the current user including privileges.""" - # For production, you would get this from authentication - # For now return basic user info with default privilege - return { - "id": self._userId, - "_mandateId": self._mandateId, - "privilege": "user", # Default privilege level - "language": self.userLanguage - } - + self.access = LucydomAccess(self.currentUser, self.db) + + def _initializeDatabase(self): """Initializes the database connection.""" - 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 - ) + try: + # Get configuration values with defaults + dbHost = APP_CONFIG.get("DB_LUCYDOM_HOST", "data") + dbDatabase = APP_CONFIG.get("DB_LUCYDOM_DATABASE", "lucydom") + dbUser = APP_CONFIG.get("DB_LUCYDOM_USER") + dbPassword = APP_CONFIG.get("DB_LUCYDOM_PASSWORD_SECRET") + + # Ensure the database directory exists + os.makedirs(dbHost, exist_ok=True) + + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + _mandateId=self._mandateId, + _userId=self._userId + ) + + # Set context + self.db.updateContext(self._mandateId, self._userId) + + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize database: {str(e)}") + raise def _initRecords(self): """Initializes standard records in the database if they don't exist.""" - # 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}") + try: + # Initialize standard prompts self._initializeStandardPrompts() - else: - logger.warning("Skipping prompt initialization - no valid context available") - + + # Add other record initializations here + + logger.info("Standard records initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize standard records: {str(e)}") + raise + def _initializeStandardPrompts(self): """Creates standard prompts if they don't exist.""" prompts = self.db.getRecordset("prompts") @@ -187,11 +169,11 @@ class LucyDOMInterface: 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) + return self.access.uam(table, recordset) def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """Delegate to access control module.""" - return self.access._canModify(table, recordId) + return self.access.canModify(table, recordId) # Language support method @@ -205,7 +187,7 @@ class LucyDOMInterface: async def callAi(self, messages: List[Dict[str, str]], produceUserAnswer: bool = False, temperature: float = None) -> str: """Enhanced AI service call with language support.""" if not self.aiService: - logger.error("AI service not set in LucyDOMInterface") + logger.error("AI service not set in LucydomInterface") return "Error: AI service not available" # Add language instruction for user-facing responses @@ -230,7 +212,7 @@ class LucyDOMInterface: async def callAi4Image(self, imageData: Union[str, bytes], mimeType: str = None, prompt: str = "Describe this image") -> str: """Enhanced AI service call with language support.""" if not self.aiService: - logger.error("AI service not set in LucyDOMInterface") + logger.error("AI service not set in LucydomInterface") return "Error: AI service not available" return await self.aiService.analyzeImage(imageData, mimeType, prompt) @@ -1237,85 +1219,24 @@ class LucyDOMInterface: logger.error(f"Error loading workflow state: {str(e)}") return None - # Microsoft Login - - def getMsftToken(self) -> Optional[Dict[str, Any]]: - """Get Microsoft token data for the current user from database""" - try: - # Get token from database using current user's mandateId and userId - tokens = self.db.getRecordset("msftTokens", recordFilter={ - "_mandateId": self._mandateId, - "_userId": self._userId - }) - - if tokens and len(tokens) > 0: - token_data = json.loads(tokens[0]["token_data"]) - logger.debug(f"Retrieved Microsoft token for user {self._userId}") - return token_data - else: - logger.debug(f"No Microsoft token found for user {self._userId}") - return None - - except Exception as e: - logger.error(f"Error retrieving Microsoft token: {str(e)}") - return None - - def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: - """Save Microsoft token data for the current user to database""" - try: - # Check if token already exists - tokens = self.db.getRecordset("msftTokens", recordFilter={ - "_mandateId": self._mandateId, - "_userId": self._userId - }) - - if tokens and len(tokens) > 0: - # Update existing token - token_id = tokens[0]["id"] - updated_data = { - "token_data": json.dumps(token_data), - "updated_at": datetime.now().isoformat() - } - self.db.recordModify("msftTokens", token_id, updated_data) - logger.debug(f"Updated Microsoft token for user {self._userId}") - else: - # Create new token with UUID - new_token = { - "_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.debug(f"Saved new Microsoft token for user {self._userId}") - - return True - - except Exception as e: - logger.error(f"Error saving Microsoft token: {str(e)}") - return False -# Singleton factory for LucyDOMInterface instances per context -_lucydomInterfaces = {} +def getInterface(currentUser: Dict[str, Any]) -> 'LucydomInterface': + """ + Returns a LucydomInterface instance for the current user. + Handles initialization of database and records. + """ + # Get user context + mandateId = currentUser.get("_mandateId") + userId = currentUser.get("id") + + if not mandateId or not userId: + raise ValueError("Invalid user context: _mandateId and id are required") -def getLucydomInterface(_mandateId: str = None, _userId: str = None) -> LucyDOMInterface: - """ - Returns a LucyDOMInterface instance for the specified context. - Ensures AI service is initialized and preserves it across instances. - """ - # For initialization, use empty strings instead of None - contextKey = f"{_mandateId or ''}_{_userId or ''}" + # Create context key + contextKey = f"{mandateId}_{userId}" - # Ensure AI service is initialized - if _aiService is None: - initializeAIService() - - # Create new instance if needed + # Create new instance if not exists if contextKey not in _lucydomInterfaces: - _lucydomInterfaces[contextKey] = LucyDOMInterface(_mandateId or '', _userId or '') - - return _lucydomInterfaces[contextKey] - -# Initialize default instance with empty strings -getLucydomInterface('', '') \ No newline at end of file + _lucydomInterfaces[contextKey] = LucydomInterface(currentUser) + + return _lucydomInterfaces[contextKey] \ No newline at end of file diff --git a/modules/interfaces/lucydomModel BACKUP.py b/modules/interfaces/lucydomModel BACKUP.py deleted file mode 100644 index 2b731c13..00000000 --- a/modules/interfaces/lucydomModel BACKUP.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -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 8c82d7bd..5c6e547b 100644 --- a/modules/interfaces/lucydomModel.py +++ b/modules/interfaces/lucydomModel.py @@ -7,6 +7,12 @@ from typing import List, Dict, Any, Optional from datetime import datetime import uuid +# Get all attributes of the model +def getModelAttributes(modelClass): + return [attr for attr in dir(modelClass) + if not callable(getattr(modelClass, attr)) + and not attr.startswith('_') + and attr not in ('metadata', 'query', 'query_class', 'label', 'field_labels')] # CORE MODELS @@ -73,23 +79,6 @@ class FileData(BaseModel): 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: 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 MODELS class ChatContent(BaseModel): @@ -194,3 +183,11 @@ class TaskPlan(BaseModel): 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") + + +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") + diff --git a/modules/interfaces/msftAccess.py b/modules/interfaces/msftAccess.py new file mode 100644 index 00000000..bdbce32c --- /dev/null +++ b/modules/interfaces/msftAccess.py @@ -0,0 +1,113 @@ +""" +Access control module for Microsoft interface. +Handles user access management and permission checks for Microsoft tokens. +""" + +from typing import Dict, Any, List, Optional + +class MsftAccess: + """ + Access control class for Microsoft interface. + Handles user access management and permission checks for Microsoft tokens. + """ + + def __init__(self, currentUser: Dict[str, Any], db): + """Initialize with user context.""" + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + self.db = db + + def uam(self, table: str, 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: + table: Name of the table + recordset: Recordset to filter based on access rules + + Returns: + Filtered recordset with access control attributes + """ + userPrivilege = self.currentUser.get("privilege", "user") + filtered_records = [] + + # Apply filtering based on privilege + if userPrivilege == "sysadmin": + 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] + else: # Regular users + # Users only see their own Microsoft tokens + filtered_records = [r for r in recordset + if r.get("_mandateId") == self._mandateId and r.get("_userId") == self._userId] + + # Add access control attributes to each record + for record in filtered_records: + record_id = record.get("id") + + # Set access control flags based on user permissions + if table == "msftTokens": + record["_hideView"] = False # Everyone can view their own tokens + record["_hideEdit"] = not self.canModify("msftTokens", record_id) + record["_hideDelete"] = not self.canModify("msftTokens", record_id) + else: + # Default access control for other tables + record["_hideView"] = False + record["_hideEdit"] = not self.canModify(table, record_id) + record["_hideDelete"] = not self.canModify(table, record_id) + + return filtered_records + + def canModify(self, table: str, recordId: Optional[str] = None) -> bool: + """ + Checks if the current user can modify (create/update/delete) records in a table. + + Args: + table: Name of the table + recordId: Optional record ID for specific record check + + Returns: + Boolean indicating permission + """ + userPrivilege = self.currentUser.get("privilege", "user") + + # System admins can modify anything + if userPrivilege == "sysadmin": + return True + + # Check specific record permissions + if recordId is not None: + # Get the record to check ownership + records = self.db.getRecordset(table, recordFilter={"id": recordId}) + if not records: + return False + + record = records[0] + + # Admins can modify anything in their mandate + if userPrivilege == "admin" and record.get("_mandateId") == self._mandateId: + return True + + # Users can only modify their own Microsoft tokens + if (record.get("_mandateId") == self._mandateId and + record.get("_userId") == self._userId): + return True + + return False + else: + # For general table modify permission (e.g., create) + # Admins can create anything in their mandate + if userPrivilege == "admin": + return True + + # Regular users can create their own Microsoft tokens + if table == "msftTokens": + return True + return False \ No newline at end of file diff --git a/modules/interfaces/msftInterface.py b/modules/interfaces/msftInterface.py new file mode 100644 index 00000000..0e004b10 --- /dev/null +++ b/modules/interfaces/msftInterface.py @@ -0,0 +1,391 @@ +""" +Microsoft interface for handling Microsoft authentication and Graph API operations. +""" + +import logging +import json +import requests +import base64 +import msal +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +import secrets +import os + +from modules.shared.configuration import APP_CONFIG +from modules.interfaces.msftModel import MsftToken, MsftUserInfo +from modules.connectors.connectorDbJson import DatabaseConnector +from modules.interfaces.msftAccess import MsftAccess + +logger = logging.getLogger(__name__) + +# Singleton factory for MsftInterface instances per context +_msftInterfaces = {} + +class MsftInterface: + """Interface for Microsoft authentication and Graph API operations""" + + def __init__(self, currentUser: Dict[str, Any]): + """Initialize the Microsoft interface""" + self.currentUser = currentUser + self._mandateId = currentUser.get("_mandateId") + self._userId = currentUser.get("id") + + if not self._mandateId or not self._userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + # Initialize configuration + self.client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID") + self.client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") + self.tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") + self.redirect_uri = APP_CONFIG.get("Service_MSFT_REDIRECT_URI") + self.authority = f"https://login.microsoftonline.com/{self.tenant_id}" + self.scopes = ["Mail.ReadWrite", "User.Read"] + + # Initialize database + self._initializeDatabase() + + # Initialize access control + self.access = MsftAccess(self.currentUser, self.db) + + # Initialize MSAL application + self.msal_app = msal.ConfidentialClientApplication( + self.client_id, + authority=self.authority, + client_credential=self.client_secret + ) + + def _initializeDatabase(self): + """Initializes the database connection.""" + try: + # Get configuration values with defaults + dbHost = APP_CONFIG.get("DB_MSFT_HOST", "data") + dbDatabase = APP_CONFIG.get("DB_MSFT_DATABASE", "msft") + dbUser = APP_CONFIG.get("DB_MSFT_USER") + dbPassword = APP_CONFIG.get("DB_MSFT_PASSWORD_SECRET") + + # Ensure the database directory exists + os.makedirs(dbHost, exist_ok=True) + + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + _mandateId=self._mandateId, + _userId=self._userId + ) + + # Set context + self.db.updateContext(self._mandateId, self._userId) + + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize database: {str(e)}") + raise + + def _uam(self, table: str, 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: + table: Name of the table + recordset: Recordset to filter based on access rules + + Returns: + Filtered recordset with access control attributes + """ + return self.access.uam(table, recordset) + + def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: + """ + Checks if the current user can modify (create/update/delete) records in a table. + + Args: + table: Name of the table + recordId: Optional record ID for specific record check + + Returns: + Boolean indicating permission + """ + return self.access.canModify(table, recordId) + + def getMsftToken(self) -> Optional[Dict[str, Any]]: + """Get Microsoft token for current user""" + try: + tokens = self.db.getRecordset("msftTokens", recordFilter={ + "_mandateId": self._mandateId, + "_userId": self._userId + }) + if not tokens: + return None + + # Apply access control + filtered_tokens = self._uam("msftTokens", tokens) + if not filtered_tokens: + return None + + return filtered_tokens[0] + except Exception as e: + logger.error(f"Error getting Microsoft token: {str(e)}") + return None + + def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: + """Save Microsoft token data""" + try: + # Check if user can modify tokens + if not self._canModify("msftTokens"): + raise PermissionError("No permission to save Microsoft token") + + # Add user and mandate IDs to token data + token_data["_mandateId"] = self._mandateId + token_data["_userId"] = self._userId + + # Check if token already exists + existing_token = self.getMsftToken() + + if existing_token: + # Update existing token + return self.db.recordUpdate("msftTokens", existing_token["id"], token_data) + else: + # Create new token record + return self.db.recordCreate("msftTokens", token_data) + + except Exception as e: + logger.error(f"Error saving Microsoft token: {str(e)}") + return False + + def deleteMsftToken(self) -> bool: + """Delete Microsoft token for current user""" + try: + if not self._canModify("msftTokens"): + raise PermissionError("No permission to delete Microsoft token") + + existing_token = self.getMsftToken() + if existing_token: + return self.db.recordDelete("msftTokens", existing_token["id"]) + return True + except Exception as e: + logger.error(f"Error deleting Microsoft token: {str(e)}") + return False + + def getCurrentUserToken(self) -> tuple: + """Get current user's Microsoft token and info""" + try: + token_data = self.getMsftToken() + if not token_data: + return None, None + + # Verify token is still valid + if not self.verifyToken(token_data.get("access_token")): + if not self.refreshToken(token_data): + return None, None + token_data = self.getMsftToken() + + user_info = token_data.get("user_info") + if not user_info: + user_info = self.getUserInfoFromToken(token_data.get("access_token")) + if user_info: + token_data["user_info"] = user_info + self.saveMsftToken(token_data) + + return user_info, token_data.get("access_token") + + except Exception as e: + logger.error(f"Error getting current user token: {str(e)}") + return None, None + + def verifyToken(self, token: str) -> bool: + """Verify the access token is valid""" + try: + headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) + return response.status_code == 200 + except Exception as e: + logger.error(f"Error verifying token: {str(e)}") + return False + + def refreshToken(self, token_data: Dict[str, Any]) -> bool: + """Refresh the access token using the stored refresh token""" + try: + if not token_data or not token_data.get("refresh_token"): + return False + + result = self.msal_app.acquire_token_by_refresh_token( + token_data["refresh_token"], + scopes=self.scopes + ) + + if "error" in result: + logger.error(f"Error refreshing token: {result.get('error')}") + return False + + token_data["access_token"] = result["access_token"] + if "refresh_token" in result: + token_data["refresh_token"] = result["refresh_token"] + + return self.saveMsftToken(token_data) + + except Exception as e: + logger.error(f"Error refreshing token: {str(e)}") + return False + + def getUserInfoFromToken(self, access_token: str) -> Optional[Dict[str, Any]]: + """Get user information using the access token""" + try: + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) + if response.status_code == 200: + user_data = response.json() + return { + "name": user_data.get("displayName", ""), + "email": user_data.get("userPrincipalName", ""), + "id": user_data.get("id", "") + } + return None + except Exception as e: + logger.error(f"Error getting user info: {str(e)}") + return None + + def createDraftEmail(self, recipient: str, subject: str, body: str, attachments: List[Dict[str, Any]] = None) -> bool: + """Create a draft email using Microsoft Graph API""" + try: + user_info, access_token = self.getCurrentUserToken() + if not user_info or not access_token: + return False + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + email_data = { + 'subject': subject, + 'body': { + 'contentType': 'HTML', + 'content': body + }, + 'toRecipients': [ + { + 'emailAddress': { + 'address': recipient + } + } + ] + } + + if attachments: + email_data['attachments'] = [] + for attachment in attachments: + doc = attachment.get('document', {}) + file_name = attachment.get('name', 'attachment.file') + + file_content = doc.get('data') + if not file_content: + continue + + mime_type = doc.get('mimeType', 'application/octet-stream') + is_base64 = doc.get('base64Encoded', False) + + try: + if is_base64: + content_bytes = file_content + else: + if isinstance(file_content, str): + content_bytes = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') + elif isinstance(file_content, bytes): + content_bytes = base64.b64encode(file_content).decode('utf-8') + else: + continue + + decoded_size = len(base64.b64decode(content_bytes)) + + attachment_data = { + '@odata.type': '#microsoft.graph.fileAttachment', + 'name': file_name, + 'contentType': mime_type, + 'contentBytes': content_bytes, + 'isInline': False, + 'size': decoded_size + } + email_data['attachments'].append(attachment_data) + + except Exception as e: + logger.error(f"Error processing attachment {file_name}: {str(e)}") + continue + + response = requests.post( + 'https://graph.microsoft.com/v1.0/me/messages', + headers=headers, + json=email_data + ) + + return response.status_code >= 200 and response.status_code < 300 + + except Exception as e: + logger.error(f"Error creating draft email: {str(e)}") + return False + + def initiateLogin(self) -> str: + """Initiate Microsoft login flow""" + try: + state = secrets.token_urlsafe(32) + auth_url = self.msal_app.get_authorization_request_url( + self.scopes, + state=state, + redirect_uri=self.redirect_uri + ) + return auth_url + except Exception as e: + logger.error(f"Error initiating login: {str(e)}") + return None + + def handleAuthCallback(self, code: str) -> Optional[Dict[str, Any]]: + """Handle Microsoft OAuth callback""" + try: + token_response = self.msal_app.acquire_token_by_authorization_code( + code, + self.scopes, + redirect_uri=self.redirect_uri + ) + + if "error" in token_response: + logger.error(f"Token acquisition failed: {token_response['error']}") + return None + + user_info = self.getUserInfoFromToken(token_response["access_token"]) + if not user_info: + return None + + token_response["user_info"] = user_info + return token_response + + except Exception as e: + logger.error(f"Error handling auth callback: {str(e)}") + return None + +def getInterface(currentUser: Dict[str, Any]) -> MsftInterface: + """ + Returns a MsftInterface instance for the current user. + Handles initialization of database and records. + """ + mandateId = currentUser.get("_mandateId") + userId = currentUser.get("id") + if not mandateId or not userId: + raise ValueError("Invalid user context: _mandateId and id are required") + + # Create context key + contextKey = f"{mandateId}_{userId}" + + # Create new instance if not exists + if contextKey not in _msftInterfaces: + _msftInterfaces[contextKey] = MsftInterface(currentUser) + + return _msftInterfaces[contextKey] \ No newline at end of file diff --git a/modules/interfaces/msftModel.py b/modules/interfaces/msftModel.py new file mode 100644 index 00000000..b3449f08 --- /dev/null +++ b/modules/interfaces/msftModel.py @@ -0,0 +1,73 @@ +""" +Data models for Microsoft integration. +""" +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from datetime import datetime +import uuid + +# Get all attributes of the model +def getModelAttributes(modelClass): + return [attr for attr in dir(modelClass) + if not callable(getattr(modelClass, attr)) + and not attr.startswith('_') + and attr not in ('metadata', 'query', 'query_class', 'label', 'field_labels')] + +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 MsftToken(BaseModel): + """Data model for Microsoft authentication token""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the token") + access_token: str = Field(description="Microsoft access token") + refresh_token: str = Field(description="Microsoft refresh token") + expires_in: int = Field(description="Token expiration time in seconds") + token_type: str = Field(description="Type of token (usually 'bearer')") + expires_at: float = Field(description="Timestamp when token expires") + user_info: Optional[Dict[str, Any]] = Field(None, description="User information from Microsoft") + _mandateId: str = Field(description="Mandate ID associated with the token") + _userId: str = Field(description="User ID associated with the token") + + 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={}), + "access_token": Label(default="Access Token", translations={"en": "Access Token", "fr": "Jeton d'accès"}), + "refresh_token": Label(default="Refresh Token", translations={"en": "Refresh Token", "fr": "Jeton de rafraîchissement"}), + "expires_in": Label(default="Expires In", translations={"en": "Expires In", "fr": "Expire dans"}), + "token_type": Label(default="Token Type", translations={"en": "Token Type", "fr": "Type de jeton"}), + "expires_at": Label(default="Expires At", translations={"en": "Expires At", "fr": "Expire à"}), + "user_info": Label(default="User Info", translations={"en": "User Info", "fr": "Info utilisateur"}), + "_mandateId": Label(default="Mandate ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "_userId": Label(default="User ID", translations={"en": "User ID", "fr": "ID utilisateur"}) + } + +class MsftUserInfo(BaseModel): + """Data model for Microsoft user information""" + name: str = Field(description="User's display name") + email: str = Field(description="User's email address") + id: str = Field(description="User's Microsoft ID") + + label: Label = Field( + default=Label(default="Microsoft User Info", translations={"en": "Microsoft User Info", "fr": "Info utilisateur Microsoft"}), + description="Label for the class" + ) + + # Labels for attributes + fieldLabels: Dict[str, Label] = { + "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), + "email": Label(default="Email", translations={"en": "Email", "fr": "E-mail"}), + "id": Label(default="ID", translations={}) + } \ No newline at end of file diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index 45f2092d..bc90e169 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -6,7 +6,7 @@ import importlib import os from pydantic import BaseModel -from modules.security.auth import getCurrentActiveUser, getUserContext +from modules.security.auth import getCurrentActiveUser # Import the attribute definition and helper functions from modules.shared.defAttributes import AttributeDefinition, getModelAttributes @@ -43,7 +43,7 @@ router = APIRouter( ) @router.get("/{entityType}", response_model=List[AttributeDefinition]) -async def getEntityAttributes( +async def get_entity_attributes( entityType: str = Path(..., description="Type of entity (e.g. prompt)"), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): @@ -51,11 +51,8 @@ async def getEntityAttributes( Retrieves the attribute definitions for a specific entity. This can be used for dynamic form generation. """ - # Authentication and user context - mandateId, userId = await getUserContext(currentUser) - # Determine preferred language of the user - userLanguage = currentUser.get("language", "de") + userLanguage = currentUser.get("language", "en") # Get model classes dynamically modelClasses = getModelClasses() @@ -75,7 +72,7 @@ async def getEntityAttributes( return [attr for attr in attributes if attr.visible] @router.options("/{entityType}") -async def optionsEntityAttributes( +async def options_entity_attributes( entityType: str = Path(..., description="Type of entity (e.g. prompt)") ): """Handle OPTIONS request for CORS preflight""" diff --git a/modules/routes/routeFiles.py b/modules/routes/routeFiles.py index 4e64a038..d23d9a15 100644 --- a/modules/routes/routeFiles.py +++ b/modules/routes/routeFiles.py @@ -2,50 +2,23 @@ from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, P from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional import logging -from datetime import datetime +from datetime import datetime, timezone from dataclasses import dataclass import io -from modules.security.auth import getCurrentActiveUser, getUserContext +from modules.security.auth import getCurrentActiveUser from modules.shared.configuration import APP_CONFIG # Import interfaces -from modules.interfaces.lucydomInterface import getLucydomInterface, FileError, FileNotFoundError, FileStorageError, FilePermissionError, FileDeletionError -from modules.interfaces.lucydomModel import FileItem +from modules.interfaces.lucydomInterface import getInterface, FileError, FileNotFoundError, FileStorageError, FilePermissionError, FileDeletionError +from modules.interfaces.lucydomModel import FileItem, getModelAttributes # Configure logger logger = logging.getLogger(__name__) -# Get all attributes of the model -def getModelAttributes(modelClass): - return [attr for attr in dir(modelClass) - if not callable(getattr(modelClass, attr)) - and not attr.startswith('_') - and attr not in ('metadata', 'query', 'query_class', 'label', 'field_labels')] - # Model attributes for FileItem fileAttributes = getModelAttributes(FileItem) -class AppContext: - 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) - - return AppContext(_mandateId, _userId) - # Create router for file endpoints router = APIRouter( prefix="/api/files", @@ -60,13 +33,13 @@ router = APIRouter( ) @router.get("", response_model=List[Dict[str, Any]]) -async def getFiles(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): +async def get_files(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): """Get all available files""" try: - context = await getContext(currentUser) + myInterface = getInterface(currentUser) # Get all files generically - only metadata, no binary data - files = context.interfaceData.getAllFiles() + files = myInterface.getAllFiles() return files except Exception as e: logger.error(f"Error retrieving files: {str(e)}") @@ -76,14 +49,14 @@ async def getFiles(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): ) @router.post("/upload", status_code=status.HTTP_201_CREATED) -async def uploadFile( +async def upload_file( file: UploadFile = File(...), workflowId: Optional[str] = Form(None), currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Upload a file""" try: - context = await getContext(currentUser) + myInterface = getInterface(currentUser) # Read file fileContent = await file.read() @@ -97,12 +70,12 @@ async def uploadFile( ) # Save file via LucyDOM interface in the database - fileMeta = context.interfaceData.saveUploadedFile(fileContent, file.filename) + fileMeta = myInterface.saveUploadedFile(fileContent, file.filename) # If workflowId is provided, update the file information if workflowId: updateData = {"workflowId": workflowId} - context.interfaceData.updateFile(fileMeta["id"], updateData) + myInterface.updateFile(fileMeta["id"], updateData) fileMeta["workflowId"] = workflowId # Successful response @@ -122,16 +95,16 @@ async def uploadFile( ) @router.get("/{fileId}") -async def getFile( +async def get_file( fileId: str, currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Returns a file by its ID for download""" try: - context = await getContext(currentUser) + myInterface = getInterface(currentUser) # Get file via LucyDOM interface from the database - fileData = context.interfaceData.downloadFile(fileId) + fileData = myInterface.downloadFile(fileId) # Return file headers = { @@ -168,17 +141,57 @@ async def getFile( detail=f"Error retrieving file: {str(e)}" ) +@router.put("/{file_id}") +async def update_file( + file_id: str, + file_data: FileItem, + current_user: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """ + Update file metadata + """ + try: + myInterface = getInterface(current_user) + + # Get the file from the database + file = myInterface.getFile(file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # Check if user has access to the file + if file.get("userId", 0) != current_user.get("id", 0): + raise HTTPException(status_code=403, detail="Not authorized to update this file") + + # Update file metadata + update_data = file_data.dict(exclude_unset=True) + update_data["modified_at"] = datetime.now(timezone.utc) + + # Update in database + result = myInterface.updateFile(file_id, update_data) + if not result: + raise HTTPException(status_code=500, detail="Failed to update file") + + # Get updated file + updated_file = myInterface.getFile(file_id) + return updated_file + + except HTTPException as he: + raise he + except Exception as e: + logger.error(f"Error updating file: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + @router.delete("/{fileId}", status_code=status.HTTP_204_NO_CONTENT) -async def deleteFile( +async def delete_file( fileId: str, currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Deletes a file by its ID from the database""" try: - context = await getContext(currentUser) + myInterface = getInterface(currentUser) # Delete file via LucyDOM interface - context.interfaceData.deleteFile(fileId) + myInterface.deleteFile(fileId) # Return successful deletion without content (204 No Content) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -209,15 +222,15 @@ async def deleteFile( ) @router.get("/stats", response_model=Dict[str, Any]) -async def getFileStats( +async def get_file_stats( currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) ): """Returns statistics about the stored files""" try: - context = await getContext(currentUser) + myInterface = getInterface(currentUser) # Get all files - metadata only - allFiles = context.interfaceData.getAllFiles() + allFiles = myInterface.getAllFiles() # Calculate statistics totalFiles = len(allFiles) diff --git a/modules/routes/routeGeneral.py b/modules/routes/routeGeneral.py index 3ce73501..82f3c3f8 100644 --- a/modules/routes/routeGeneral.py +++ b/modules/routes/routeGeneral.py @@ -12,11 +12,11 @@ from modules.shared.configuration import APP_CONFIG from modules.security.auth import ( createAccessToken, getCurrentActiveUser, - getUserContext, + getRootInterface, ACCESS_TOKEN_EXPIRE_MINUTES ) import modules.interfaces.gatewayModel as gatewayModel -from modules.interfaces.gatewayInterface import getGatewayInterface +from modules.interfaces.gatewayInterface import getInterface router = APIRouter() @@ -40,11 +40,11 @@ async def root(): return {"status": "online", "message": "Data Platform API is active"} @router.get("/api/test", tags=["General"]) -async def getTest(): +async def get_test(): return f"Status: OK. Alowed origins: {APP_CONFIG.get('APP_ALLOWED_ORIGINS')}" @router.options("/{fullPath:path}", tags=["General"]) -async def optionsRoute(fullPath: str): +async def options_route(fullPath: str): return Response(status_code=200) @router.get("/api/environment", tags=["General"]) @@ -58,24 +58,13 @@ async def get_environment(): } @router.post("/api/token", response_model=gatewayModel.Token, tags=["General"]) -async def loginForAccessToken(formData: OAuth2PasswordRequestForm = Depends()): - # 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" - ) - +async def login_for_access_token(formData: OAuth2PasswordRequestForm = Depends()): # Create a new gateway interface instance with admin context - adminGateway = getGatewayInterface(rootMandateId, adminUserId) + myInterface = getRootInterface() try: # Authenticate user - user = adminGateway.authenticateUser(formData.username, formData.password) + user = myInterface.authenticateUser(formData.username, formData.password) # Create token with mandate ID and user ID accessTokenExpires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) @@ -110,30 +99,17 @@ async def loginForAccessToken(formData: OAuth2PasswordRequestForm = Depends()): ) @router.get("/api/user/me", response_model=Dict[str, Any], tags=["General"]) -async def readUserMe(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): +async def read_user_me(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): return currentUser @router.post("/api/users/register", response_model=Dict[str, Any], tags=["General"]) -async def registerUser(userData: Dict[str, Any]): +async def register_user(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" - ) + logger.debug("Received registration request") # Create a new gateway interface instance with admin context - adminGateway = getGatewayInterface(rootMandateId, adminUserId) + myInterface = getRootInterface() # Check required fields if not userData or not isinstance(userData, dict): @@ -148,79 +124,56 @@ async def registerUser(userData: Dict[str, Any]): detail="Username and password are required" ) - # Create user data with mandate ID + # Create user data in same mandate as admin user userData = { "username": userData["username"], "password": userData["password"], "email": userData.get("email"), "fullName": userData.get("fullName"), - "language": userData.get("language", "de"), - "_mandateId": rootMandateId, + "language": userData.get("language", "en"), "disabled": False, "privilege": "user" } # Create the user - createdUser = adminGateway.createUser(**userData) + try: + createdUser = myInterface.createUser(**userData) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + # Verify the user was created 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 HTTPException: + raise 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'}") + logger.error(f"Error in user registration: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to register user" - ) + detail=f"Registration failed: {str(e)}" + ) @router.get("/api/user/available", response_model=Dict[str, Any], tags=["General"]) -async def checkUsernameAvailability( +async def check_username_availability( username: str, authenticationAuthority: str = "local" ): """Check if a username is available for registration""" try: - # 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) + # Create a new gateway interface instance with root context + myInterface = getRootInterface() # Check if user exists - existingUser = adminGateway.getUserByUsername(username) + existingUser = myInterface.getUserByUsername(username) if not existingUser: return {"available": True} diff --git a/modules/routes/routeMandates.py b/modules/routes/routeMandates.py index ce7f784c..aa98891a 100644 --- a/modules/routes/routeMandates.py +++ b/modules/routes/routeMandates.py @@ -1,187 +1,189 @@ -from fastapi import APIRouter, HTTPException, Depends, Body, Path -from typing import List, Dict, Any -from fastapi import status -from datetime import datetime -from dataclasses import dataclass +from fastapi import APIRouter, HTTPException, Depends, Body, status +from typing import Dict, Any, List +import logging -# Import interfaces -from modules.security.auth import getCurrentActiveUser, getUserContext -from modules.interfaces.gatewayInterface import getGatewayInterface -from modules.interfaces.gatewayModel import Mandate +from modules.security.auth import getCurrentActiveUser +from modules.interfaces.gatewayInterface import getInterface +from modules.interfaces.gatewayModel import Mandate, getModelAttributes -# Determine all attributes of the model -def getModelAttributes(modelClass): - return [attr for attr in dir(modelClass) - if not callable(getattr(modelClass, attr)) - and not attr.startswith('_') - and attr not in ('metadata', 'query', 'query_class', 'label', 'field_labels')] +# Configure logger +logger = logging.getLogger(__name__) # Model attributes for Mandate mandateAttributes = getModelAttributes(Mandate) -class AppContext: - 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: - mandateId, userId = await getUserContext(currentUser) - return AppContext(mandateId, userId) - -# Create router for mandate endpoints router = APIRouter( prefix="/api/mandates", tags=["Mandates"], responses={404: {"description": "Not found"}} ) -@router.get("", response_model=List[Dict[str, Any]]) -async def getMandates(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): - """Get all available mandates (only for SysAdmin users)""" - context = await getContext(currentUser) - - # Permission check - if currentUser.get("privilege") != "sysadmin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only system administrators can access all mandates" - ) - - # Get mandates - return context.interfaceData.getAllMandates() - -@router.post("", response_model=Dict[str, Any]) -async def createMandate( - mandate: Dict[str, Any] = Body(...), - currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) -): - """Create a new mandate (only for SysAdmin users)""" - context = await getContext(currentUser) - - # Permission check - if currentUser.get("privilege") != "sysadmin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only system administrators can create mandates" - ) - - # Set attributes from the request dynamically - mandateData = {} - for attr in mandateAttributes: - if attr in mandate: - mandateData[attr] = mandate[attr] - - # Default values for missing fields - mandateData.setdefault("name", "New Mandate") - mandateData.setdefault("language", "de") - - # Create mandate - newMandate = context.interfaceData.createMandate(**mandateData) - - return newMandate - -@router.get("/{_mandateId}", response_model=Dict[str, Any]) -async def getMandate( - _mandateId: str = Path(..., description="ID of the mandate"), - currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) -): - """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 - - if (isAdmin and not isOwnMandate) and not isSysadmin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No permission to access this mandate" - ) - - # 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" - ) - - return mandate - -@router.put("/{_mandateId}", response_model=Dict[str, Any]) -async def updateMandate( - _mandateId: str = Path(..., description="ID of the mandate to update"), - mandateData: Dict[str, Any] = Body(...), - currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) -): - """Update a mandate.""" - context = await getContext(currentUser) - - # 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" - ) - - # Permission check - isAdmin = currentUser.get("privilege") == "admin" - isSysadmin = currentUser.get("privilege") == "sysadmin" - isOwnMandate = context._mandateId == _mandateId - - if (isAdmin and not isOwnMandate) and not isSysadmin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No permission to update this mandate" - ) - - # Dynamically filter attributes from the request into updateData - updateData = {} - for attr in mandateAttributes: - if attr in mandateData: - updateData[attr] = mandateData[attr] - - # Update mandate - updatedMandate = context.interfaceData.updateMandate(_mandateId, mandateData) - return updatedMandate - -@router.delete("/{_mandateId}", status_code=status.HTTP_204_NO_CONTENT) -async def deleteMandate( - _mandateId: str = Path(..., description="ID of the mandate to delete"), - currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) -): - """Delete a mandate.""" - context = await getContext(currentUser) - - # 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" - ) - - # Permission check - isAdmin = currentUser.get("privilege") == "admin" - isSysadmin = currentUser.get("privilege") == "sysadmin" - isOwnMandate = context._mandateId == _mandateId - - if (isAdmin and not isOwnMandate) and not isSysadmin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="No permission to delete this mandate" - ) - - # Delete mandate - success = context.interfaceData.deleteMandate(_mandateId) - if not success: +@router.get("/", response_model=List[Dict[str, Any]], tags=["Mandates"]) +async def get_mandates(currentUser: Dict[str, Any] = Depends(getCurrentActiveUser)): + """Get all mandates""" + try: + myInterface = getInterface(currentUser) + return myInterface.getMandates() + except Exception as e: + logger.error(f"Error getting mandates: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error deleting mandate with ID {_mandateId}" + detail=f"Failed to get mandates: {str(e)}" ) - - return None \ No newline at end of file + +@router.get("/{mandateId}", response_model=Dict[str, Any], tags=["Mandates"]) +async def get_mandate( + mandateId: str, + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """Get a specific mandate by ID""" + try: + myInterface = getInterface(currentUser) + mandate = myInterface.getMandateById(mandateId) + + if not mandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + return mandate + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting mandate {mandateId}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get mandate: {str(e)}" + ) + +@router.post("/", response_model=Dict[str, Any], tags=["Mandates"]) +async def create_mandate( + mandateData: Dict[str, Any], + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """Create a new mandate""" + try: + myInterface = getInterface(currentUser) + + # Check required fields + if not mandateData.get("name"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Mandate name is required" + ) + + # Filter attributes based on model definition + filteredData = {} + for attr in mandateAttributes: + if attr in mandateData: + filteredData[attr] = mandateData[attr] + + try: + createdMandate = myInterface.createMandate(**filteredData) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + if not createdMandate: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create mandate" + ) + + return createdMandate + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating mandate: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create mandate: {str(e)}" + ) + +@router.put("/{mandateId}", response_model=Dict[str, Any], tags=["Mandates"]) +async def update_mandate( + mandateId: str, + mandateData: Dict[str, Any], + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """Update an existing mandate""" + try: + myInterface = getInterface(currentUser) + + # Check if mandate exists + existingMandate = myInterface.getMandateById(mandateId) + if not existingMandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + # Filter attributes based on model definition + filteredData = {} + for attr in mandateAttributes: + if attr in mandateData: + filteredData[attr] = mandateData[attr] + + # Update mandate data + try: + updatedMandate = myInterface.updateMandate(mandateId, **filteredData) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + if not updatedMandate: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update mandate" + ) + + return updatedMandate + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating mandate {mandateId}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update mandate: {str(e)}" + ) + +@router.delete("/{mandateId}", response_model=Dict[str, Any], tags=["Mandates"]) +async def delete_mandate( + mandateId: str, + currentUser: Dict[str, Any] = Depends(getCurrentActiveUser) +): + """Delete a mandate""" + try: + myInterface = getInterface(currentUser) + + # Check if mandate exists + existingMandate = myInterface.getMandateById(mandateId) + if not existingMandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + # Delete mandate + try: + myInterface.deleteMandate(mandateId) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + return {"message": f"Mandate {mandateId} deleted successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting mandate {mandateId}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete mandate: {str(e)}" + ) \ No newline at end of file diff --git a/modules/routes/routeMsft.py b/modules/routes/routeMsft.py index 533b59ef..12352d50 100644 --- a/modules/routes/routeMsft.py +++ b/modules/routes/routeMsft.py @@ -1,16 +1,12 @@ -from fastapi import APIRouter, HTTPException, Depends, Request, Response, status, Cookie +from fastapi import APIRouter, HTTPException, Depends, Request, Response, status, Cookie, Body from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse -import msal import logging import json from typing import Dict, Any, Optional, List from datetime import datetime, timedelta -import secrets -from modules.security.auth import getCurrentActiveUser, getUserContext, createAccessToken, ACCESS_TOKEN_EXPIRE_MINUTES -from modules.shared.configuration import APP_CONFIG -from modules.interfaces.lucydomInterface import getLucydomInterface -from modules.interfaces.gatewayInterface import getGatewayInterface +from modules.security.auth import getCurrentActiveUser, createAccessToken, ACCESS_TOKEN_EXPIRE_MINUTES, getRootInterface +from modules.interfaces.msftInterface import getInterface as getMsftInterface # Configure logger logger = logging.getLogger(__name__) @@ -28,189 +24,22 @@ router = APIRouter( } ) -# Azure AD configuration - load from config -CLIENT_ID = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_ID") -CLIENT_SECRET = APP_CONFIG.get("Agent_Mail_MSFT_CLIENT_SECRET") -TENANT_ID = APP_CONFIG.get("Agent_Mail_MSFT_TENANT_ID", "common") # Use 'common' for multi-tenant -AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" -SCOPES = ["Mail.ReadWrite", "User.Read"] -REDIRECT_URI = APP_CONFIG.get("Agent_Mail_MSFT_REDIRECT_URI") - -# Initialize MSAL application -app_config = { - "client_id": CLIENT_ID, - "client_credential": CLIENT_SECRET, - "authority": AUTHORITY, - "redirect_uri": REDIRECT_URI -} - -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: - logger.error("No user context available for token storage") - return False - - # Get LucyDOM interface for current user - mydom = getLucydomInterface( - _mandateId=_mandateId, - _userId=_userId - ) - if not mydom: - logger.error("No LucyDOM interface available for token storage") - return False - - # Ensure user info is preserved - if "user_info" not in token_data: - # Try to get user info from the token - user_info = get_user_info_from_token(token_data.get("access_token", "")) - if user_info: - token_data["user_info"] = user_info - - # Save token to database - success = mydom.saveMsftToken(token_data) - if success: - logger.info("Token saved successfully to database") - return True - else: - logger.error("Failed to save token to database") - return False - - except Exception as e: - logger.error(f"Error saving token: {str(e)}") - return False - -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: - logger.error("No user context available for token retrieval") - return None - - # Get LucyDOM interface for current user - mydom = getLucydomInterface( - _mandateId=_mandateId, - _userId=_userId - ) - if not mydom: - logger.error("No LucyDOM interface available for token retrieval") - return None - - # Get token from database - token_data = mydom.getMsftToken() - if token_data: - logger.info("Token loaded successfully from database") - return token_data - else: - logger.info("No token found in database") - return None - - except Exception as e: - logger.error(f"Error loading token: {str(e)}") - return None - -def get_user_info_from_token(access_token: str) -> Optional[Dict[str, Any]]: - """Get user information using the access token""" - import requests - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - - try: - response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) - if response.status_code == 200: - user_data = response.json() - return { - "name": user_data.get("displayName", ""), - "email": user_data.get("userPrincipalName", ""), - "id": user_data.get("id", "") - } - else: - logger.error(f"Error getting user info: {response.status_code} - {response.text}") - return None - except Exception as e: - logger.error(f"Exception getting user info: {str(e)}") - return None - -def verify_token(token: str) -> bool: - """Verify the access token is valid""" - import requests - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - try: - logger.info("Verifying token validity...") - response = requests.get('https://graph.microsoft.com/v1.0/me', headers=headers) - - if response.status_code == 200: - logger.info("Token verification successful") - return True - else: - logger.error(f"Token verification failed: {response.status_code} - {response.text}") - return False - except Exception as e: - logger.error(f"Exception verifying token: {str(e)}") - return False - -async def refresh_token(user_id: str, currentUser: Dict[str, Any]) -> bool: - """Refresh the access token using the stored refresh token""" - token_data = await load_token_from_file(currentUser) - if not token_data or not token_data.get("refresh_token"): - logger.warning("No refresh token available") - return False - - msal_app = msal.ConfidentialClientApplication( - app_config["client_id"], - authority=app_config["authority"], - client_credential=app_config["client_credential"] - ) - - result = msal_app.acquire_token_by_refresh_token( - token_data["refresh_token"], - scopes=SCOPES - ) - - if "error" in result: - logger.error(f"Error refreshing token: {result.get('error')}") - return False - - # Update tokens in storage - token_data["access_token"] = result["access_token"] - if "refresh_token" in result: - token_data["refresh_token"] = result["refresh_token"] - - await save_token_to_file(token_data, currentUser) - logger.info("Access token refreshed successfully") - return True - @router.get("/login") async def login(): """Initiate Microsoft login for the current user""" try: - # Create a confidential client application - msal_app = msal.ConfidentialClientApplication( - app_config["client_id"], - authority=app_config["authority"], - client_credential=app_config["client_credential"] - ) + # Get Microsoft interface + msft = getMsftInterface({"_mandateId": "root", "id": "root"}) - # Build the auth URL with a random state - state = secrets.token_urlsafe(32) + # Get login URL + auth_url = msft.initiateLogin() + if not auth_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to initiate Microsoft login" + ) - auth_url = msal_app.get_authorization_request_url( - SCOPES, - state=state, # Use random state - redirect_uri=app_config["redirect_uri"] - ) - - logger.info(f"Redirecting to Microsoft login") + logger.info("Redirecting to Microsoft login") return RedirectResponse(auth_url) except Exception as e: @@ -224,22 +53,12 @@ async def login(): async def auth_callback(code: str, state: str, request: Request): """Handle Microsoft OAuth callback""" try: - # Create a confidential client application - msal_app = msal.ConfidentialClientApplication( - app_config["client_id"], - authority=app_config["authority"], - client_credential=app_config["client_credential"] - ) + # Get Microsoft interface + msft = getMsftInterface({"_mandateId": "root", "id": "root"}) - # Exchange the authorization code for tokens - token_response = msal_app.acquire_token_by_authorization_code( - code, - SCOPES, - redirect_uri=app_config["redirect_uri"] - ) - - if "error" in token_response: - logger.error(f"Token acquisition failed: {token_response['error']}") + # Handle auth callback + token_response = msft.handleAuthCallback(code) + if not token_response: return HTMLResponse( content=""" @@ -262,38 +81,11 @@ async def auth_callback(code: str, state: str, request: Request): status_code=400 ) - # Get user info from the token - user_info = get_user_info_from_token(token_response["access_token"]) - - if not user_info: - logger.error("Failed to get user info from token") - return HTMLResponse( - content=""" - - - Authentication Failed - - - -

Authentication Failed

-

Could not retrieve user information.

- - - - """, - status_code=400 - ) - # Get gateway interface for user operations - gateway = getGatewayInterface() + gateway = getRootInterface() # Check if user exists - user = gateway.getUserByUsername(user_info["email"]) + user = gateway.getUserByUsername(token_response["user_info"]["email"]) # If user doesn't exist, create a new user in the default mandate if not user: @@ -305,16 +97,16 @@ async def auth_callback(code: str, state: str, request: Request): # Create new user with Microsoft authentication user = gateway.createUser( - username=user_info["email"], - email=user_info["email"], - fullName=user_info.get("name", user_info["email"]), + username=token_response["user_info"]["email"], + email=token_response["user_info"]["email"], + fullName=token_response["user_info"].get("name", token_response["user_info"]["email"]), _mandateId=rootMandateId, authenticationAuthority="microsoft" ) - logger.info(f"Created new user for Microsoft account: {user_info['email']}") + logger.info(f"Created new user for Microsoft account: {token_response['user_info']['email']}") # Verify user was created by retrieving it - user = gateway.getUserByUsername(user_info["email"]) + user = gateway.getUserByUsername(token_response["user_info"]["email"]) if not user: raise ValueError("Failed to retrieve created user") @@ -354,9 +146,6 @@ async def auth_callback(code: str, state: str, request: Request): expiresDelta=access_token_expires ) - # Add user info to token response - token_response["user_info"] = user_info - # Store tokens in session storage for the frontend to pick up response = HTMLResponse( content=f""" @@ -370,7 +159,7 @@ async def auth_callback(code: str, state: str, request: Request):

Authentication Successful

-

Welcome, {user_info.get('name', 'User')}!

+

Welcome, {token_response['user_info'].get('name', 'User')}!

This window will close automatically.