Merge pull request #88 from valueonag/feat/saas-multi-tenant-mandates
Feat/saas multi tenant mandates
This commit is contained in:
commit
26a2af1af8
172 changed files with 32122 additions and 5947 deletions
156
app.py
156
app.py
|
|
@ -19,8 +19,9 @@ from datetime import datetime
|
|||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.eventManagement import eventManager
|
||||
from modules.features import featuresLifecycle as featuresLifecycle
|
||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||
from modules.workflows.automation import subAutomationSchedule
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.system.registry import loadFeatureMainModules
|
||||
|
||||
class DailyRotatingFileHandler(RotatingFileHandler):
|
||||
"""
|
||||
|
|
@ -46,6 +47,9 @@ class DailyRotatingFileHandler(RotatingFileHandler):
|
|||
|
||||
def _updateFileIfNeeded(self):
|
||||
"""Update the log file if the date has changed"""
|
||||
# Guard against interpreter shutdown when datetime may be None
|
||||
if datetime is None:
|
||||
return False
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
if self.currentDate != today:
|
||||
|
|
@ -145,21 +149,24 @@ def initLogging():
|
|||
def filter(self, record):
|
||||
if isinstance(record.msg, str):
|
||||
# Remove only emojis, preserve other Unicode characters like quotes
|
||||
|
||||
# Remove emoji characters specifically
|
||||
record.msg = "".join(
|
||||
char
|
||||
for char in record.msg
|
||||
if unicodedata.category(char) != "So"
|
||||
or not (
|
||||
0x1F600 <= ord(char) <= 0x1F64F
|
||||
or 0x1F300 <= ord(char) <= 0x1F5FF
|
||||
or 0x1F680 <= ord(char) <= 0x1F6FF
|
||||
or 0x1F1E0 <= ord(char) <= 0x1F1FF
|
||||
or 0x2600 <= ord(char) <= 0x26FF
|
||||
or 0x2700 <= ord(char) <= 0x27BF
|
||||
# Guard against None characters during shutdown
|
||||
try:
|
||||
record.msg = "".join(
|
||||
char
|
||||
for char in record.msg
|
||||
if char is not None and unicodedata.category(char) != "So"
|
||||
or (char is not None and not (
|
||||
0x1F600 <= ord(char) <= 0x1F64F
|
||||
or 0x1F300 <= ord(char) <= 0x1F5FF
|
||||
or 0x1F680 <= ord(char) <= 0x1F6FF
|
||||
or 0x1F1E0 <= ord(char) <= 0x1F1FF
|
||||
or 0x2600 <= ord(char) <= 0x26FF
|
||||
or 0x2700 <= ord(char) <= 0x27BF
|
||||
))
|
||||
)
|
||||
)
|
||||
except (TypeError, AttributeError):
|
||||
# Handle edge cases during shutdown
|
||||
pass
|
||||
return True
|
||||
|
||||
# Add filter to normalize problematic unicode (e.g., arrows) to ASCII for terminals like cp1252
|
||||
|
|
@ -279,36 +286,73 @@ instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
|
|||
async def lifespan(app: FastAPI):
|
||||
logger.info("Application is starting up")
|
||||
|
||||
# Initialize AI connectors once at startup to avoid per-request discovery
|
||||
from modules.aicore.aicoreModelRegistry import modelRegistry
|
||||
modelRegistry.ensureConnectorsRegistered()
|
||||
|
||||
# Get event user for feature lifecycle (system-level user for background operations)
|
||||
rootInterface = getRootInterface()
|
||||
eventUser = rootInterface.getUserByUsername("event")
|
||||
if not eventUser:
|
||||
logger.error("Could not get event user - some features may not start properly")
|
||||
|
||||
# --- Init Feature Containers (Plug&Play) ---
|
||||
try:
|
||||
mainModules = loadFeatureMainModules()
|
||||
for featureName, module in mainModules.items():
|
||||
if hasattr(module, "onStart"):
|
||||
try:
|
||||
await module.onStart(eventUser)
|
||||
logger.info(f"Feature '{featureName}' started")
|
||||
except Exception as e:
|
||||
logger.error(f"Feature '{featureName}' failed to start: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not initialize feature containers: {e}")
|
||||
|
||||
# --- Init Managers ---
|
||||
await featuresLifecycle.start(eventUser)
|
||||
await subAutomationSchedule.start(eventUser) # Automation scheduler
|
||||
eventManager.start()
|
||||
|
||||
# Register audit log cleanup scheduler
|
||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||
registerAuditLogCleanupScheduler()
|
||||
|
||||
yield
|
||||
|
||||
# --- Stop Managers ---
|
||||
eventManager.stop()
|
||||
await featuresLifecycle.stop(eventUser)
|
||||
await subAutomationSchedule.stop(eventUser) # Automation scheduler
|
||||
|
||||
# --- Stop Feature Containers (Plug&Play) ---
|
||||
try:
|
||||
mainModules = loadFeatureMainModules()
|
||||
for featureName, module in mainModules.items():
|
||||
if hasattr(module, "onStop"):
|
||||
try:
|
||||
await module.onStop(eventUser)
|
||||
logger.info(f"Feature '{featureName}' stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Feature '{featureName}' failed to stop: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not shutdown feature containers: {e}")
|
||||
|
||||
logger.info("Application has been shut down")
|
||||
|
||||
|
||||
# Custom function to generate readable operation IDs for Swagger UI
|
||||
# Uses snake_case function names directly instead of auto-generated IDs
|
||||
def _generateOperationId(route) -> str:
|
||||
"""Generate operation ID from route function name (snake_case)."""
|
||||
if hasattr(route, "endpoint") and hasattr(route.endpoint, "__name__"):
|
||||
return route.endpoint.__name__
|
||||
return route.name if route.name else "unknown"
|
||||
|
||||
|
||||
# START APP
|
||||
app = FastAPI(
|
||||
title="PowerOn | Data Platform API",
|
||||
description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})",
|
||||
title="PowerOn AG | Workflow Engine",
|
||||
description=f"API for dynamic SaaS platforms ({instanceLabel})",
|
||||
lifespan=lifespan,
|
||||
swagger_ui_init_oauth={
|
||||
"usePkceWithAuthorizationCodeGrant": True,
|
||||
},
|
||||
generate_unique_id_function=_generateOperationId,
|
||||
)
|
||||
|
||||
# Configure OpenAPI security scheme for Swagger UI
|
||||
|
|
@ -365,7 +409,7 @@ app.add_middleware(
|
|||
CORSMiddleware,
|
||||
allow_origins=getAllowedOrigins(),
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"],
|
||||
max_age=86400, # Increased caching for preflight requests
|
||||
|
|
@ -405,23 +449,14 @@ app.include_router(userRouter)
|
|||
from modules.routes.routeDataFiles import router as fileRouter
|
||||
app.include_router(fileRouter)
|
||||
|
||||
from modules.routes.routeDataNeutralization import router as neutralizationRouter
|
||||
app.include_router(neutralizationRouter)
|
||||
|
||||
from modules.routes.routeDataPrompts import router as promptRouter
|
||||
app.include_router(promptRouter)
|
||||
|
||||
from modules.routes.routeDataConnections import router as connectionsRouter
|
||||
app.include_router(connectionsRouter)
|
||||
|
||||
from modules.routes.routeWorkflows import router as workflowRouter
|
||||
app.include_router(workflowRouter)
|
||||
|
||||
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
|
||||
app.include_router(chatPlaygroundRouter)
|
||||
|
||||
from modules.routes.routeRealEstate import router as realEstateRouter
|
||||
app.include_router(realEstateRouter)
|
||||
from modules.routes.routeDataWorkflows import router as dataWorkflowsRouter
|
||||
app.include_router(dataWorkflowsRouter)
|
||||
|
||||
from modules.routes.routeSecurityLocal import router as localRouter
|
||||
app.include_router(localRouter)
|
||||
|
|
@ -441,24 +476,49 @@ app.include_router(adminSecurityRouter)
|
|||
from modules.routes.routeSharepoint import router as sharepointRouter
|
||||
app.include_router(sharepointRouter)
|
||||
|
||||
from modules.routes.routeDataAutomation import router as automationRouter
|
||||
app.include_router(automationRouter)
|
||||
|
||||
from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter
|
||||
app.include_router(adminAutomationEventsRouter)
|
||||
|
||||
from modules.routes.routeRbac import router as rbacRouter
|
||||
app.include_router(rbacRouter)
|
||||
|
||||
from modules.routes.routeOptions import router as optionsRouter
|
||||
app.include_router(optionsRouter)
|
||||
from modules.routes.routeAdminRbacRules import router as rbacAdminRulesRouter
|
||||
app.include_router(rbacAdminRulesRouter)
|
||||
|
||||
from modules.routes.routeMessaging import router as messagingRouter
|
||||
app.include_router(messagingRouter)
|
||||
|
||||
from modules.routes.routeChatbot import router as chatbotRouter
|
||||
app.include_router(chatbotRouter)
|
||||
# Phase 8: New Feature Routes
|
||||
from modules.routes.routeAdminFeatures import router as featuresAdminRouter
|
||||
app.include_router(featuresAdminRouter)
|
||||
|
||||
from modules.routes.routeDataTrustee import router as trusteeRouter
|
||||
app.include_router(trusteeRouter)
|
||||
from modules.routes.routeInvitations import router as invitationsRouter
|
||||
app.include_router(invitationsRouter)
|
||||
|
||||
from modules.routes.routeNotifications import router as notificationsRouter
|
||||
app.include_router(notificationsRouter)
|
||||
|
||||
from modules.routes.routeAdminRbacExport import router as rbacAdminExportRouter
|
||||
app.include_router(rbacAdminExportRouter)
|
||||
|
||||
from modules.routes.routeAdminUserAccessOverview import router as userAccessOverviewRouter
|
||||
app.include_router(userAccessOverviewRouter)
|
||||
|
||||
from modules.routes.routeGdpr import router as gdprRouter
|
||||
app.include_router(gdprRouter)
|
||||
|
||||
from modules.routes.routeChat import router as chatRouter
|
||||
app.include_router(chatRouter)
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEM ROUTES (Navigation, etc.)
|
||||
# ============================================================================
|
||||
from modules.routes.routeSystem import router as systemRouter, navigationRouter
|
||||
app.include_router(systemRouter)
|
||||
app.include_router(navigationRouter)
|
||||
|
||||
# ============================================================================
|
||||
# PLUG&PLAY FEATURE ROUTERS
|
||||
# Dynamically load routers from feature containers in modules/features/
|
||||
# ============================================================================
|
||||
from modules.system.registry import loadFeatureRouters
|
||||
|
||||
featureLoadResults = loadFeatureRouters(app)
|
||||
logger.info(f"Feature router load results: {featureLoadResults}")
|
||||
|
|
|
|||
34
env_dev.env
34
env_dev.env
|
|
@ -8,40 +8,18 @@ APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
|
|||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=localhost
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=poweron_dev
|
||||
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=localhost
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=poweron_dev
|
||||
DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=localhost
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=poweron_dev
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=localhost
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
|
|
|
|||
32
env_int.env
32
env_int.env
|
|
@ -8,33 +8,11 @@ APP_KEY_SYSVAR = CONFIG_KEY
|
|||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=heeshkdlby
|
||||
DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=heeshkdlby
|
||||
DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=heeshkdlby
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_USER=heeshkdlby
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
|
|
|
|||
32
env_prod.env
32
env_prod.env
|
|
@ -8,33 +8,11 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
|
|||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://gateway-prod.poweron-center.net
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=gzxxmcrdhn
|
||||
DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=gzxxmcrdhn
|
||||
DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=gzxxmcrdhn
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_USER=gzxxmcrdhn
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import importlib
|
|||
import os
|
||||
from typing import Dict, List, Optional, Any
|
||||
from modules.datamodels.datamodelAi import AiModel
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.security.rbacHelpers import checkResourceAccess
|
||||
from modules.security.rbac import RbacClass
|
||||
|
|
|
|||
|
|
@ -72,10 +72,16 @@ class ModelSelector:
|
|||
promptSize = len(prompt.encode("utf-8"))
|
||||
contextSize = len(context.encode("utf-8"))
|
||||
totalSize = promptSize + contextSize
|
||||
# Convert bytes to approximate tokens (1 token ≈ 4 bytes)
|
||||
promptTokens = promptSize / 4
|
||||
contextTokens = contextSize / 4
|
||||
totalTokens = totalSize / 4
|
||||
# Convert bytes to approximate tokens
|
||||
# Conservative estimate: 1 token ≈ 2 bytes (for safety margin)
|
||||
# Note: Actual tokenization varies by content type and model
|
||||
# - English text: ~4 bytes/token
|
||||
# - Structured data/JSON: ~2-3 bytes/token
|
||||
# - Base64/encoded data: ~1.5-2 bytes/token
|
||||
bytesPerToken = 2 # Conservative estimate for mixed content
|
||||
promptTokens = promptSize / bytesPerToken
|
||||
contextTokens = contextSize / bytesPerToken
|
||||
totalTokens = totalSize / bytesPerToken
|
||||
|
||||
logger.debug(f"Request sizes - Prompt: {promptTokens:.0f} tokens ({promptSize} bytes), Context: {contextTokens:.0f} tokens ({contextSize} bytes), Total: {totalTokens:.0f} tokens ({totalSize} bytes)")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import os
|
|||
from typing import Dict, Any, List
|
||||
from fastapi import HTTPException
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
|
||||
|
||||
# Configure logger
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# All rights reserved.
|
||||
import logging
|
||||
from typing import List
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
|
||||
|
||||
# Configure logger
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import httpx
|
|||
from typing import List
|
||||
from fastapi import HTTPException
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -298,7 +298,6 @@ class AiOpenai(BaseConnectorAi):
|
|||
promptContent = messages[0]["content"] if messages else ""
|
||||
|
||||
# Parse prompt using AiCallPromptImage model
|
||||
from modules.datamodels.datamodelAi import AiCallPromptImage
|
||||
import json
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import httpx
|
|||
from typing import List
|
||||
from fastapi import HTTPException
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl, AiCallOptions
|
||||
from modules.datamodels.datamodelTools import CountryCodes
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -184,7 +184,6 @@ class AiPerplexity(BaseConnectorAi):
|
|||
]
|
||||
|
||||
# Create a model call for testing
|
||||
from modules.datamodels.datamodelAi import AiCallOptions
|
||||
model = self.getModels()[0] # Get first model for testing
|
||||
testCall = AiModelCall(
|
||||
messages=testMessages,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|||
from typing import Optional, List, Dict
|
||||
from tavily import AsyncTavilyClient
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.aicore.aicoreBase import BaseConnectorAi
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl
|
||||
from modules.datamodels.datamodelTools import CountryCodes
|
||||
|
||||
|
|
@ -728,8 +728,7 @@ class AiTavily(BaseConnectorAi):
|
|||
maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth
|
||||
)
|
||||
|
||||
# If we got multiple pages from the crawl, we need to format them differently
|
||||
# Return the first result for backwards compatibility, but include total page count
|
||||
# Format multiple pages from the crawl into a single response
|
||||
if crawlResults and len(crawlResults) > 0:
|
||||
# Get all pages content with error handling
|
||||
allContent = ""
|
||||
|
|
|
|||
|
|
@ -3,9 +3,23 @@
|
|||
"""
|
||||
Authentication and authorization modules for routes and services.
|
||||
High-level security functionality that depends on FastAPI and interfaces.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- RequestContext: Per-request context with user, mandate, feature instance, roles
|
||||
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
|
||||
- requireSysAdmin: FastAPI dependency for system-level admin operations
|
||||
"""
|
||||
|
||||
from .authentication import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, cookieAuth
|
||||
from .authentication import (
|
||||
getCurrentUser,
|
||||
limiter,
|
||||
SECRET_KEY,
|
||||
ALGORITHM,
|
||||
cookieAuth,
|
||||
RequestContext,
|
||||
getRequestContext,
|
||||
requireSysAdmin,
|
||||
)
|
||||
from .jwtService import (
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
|
|
@ -20,22 +34,30 @@ from .tokenRefreshMiddleware import TokenRefreshMiddleware, ProactiveTokenRefres
|
|||
from .csrf import CSRFMiddleware
|
||||
|
||||
__all__ = [
|
||||
# Authentication
|
||||
"getCurrentUser",
|
||||
"limiter",
|
||||
"SECRET_KEY",
|
||||
"ALGORITHM",
|
||||
"cookieAuth",
|
||||
# Multi-Tenant Context
|
||||
"RequestContext",
|
||||
"getRequestContext",
|
||||
"requireSysAdmin",
|
||||
# JWT Service
|
||||
"createAccessToken",
|
||||
"createRefreshToken",
|
||||
"setAccessTokenCookie",
|
||||
"setRefreshTokenCookie",
|
||||
"clearAccessTokenCookie",
|
||||
"clearRefreshTokenCookie",
|
||||
# Token Management
|
||||
"TokenManager",
|
||||
"token_refresh_service",
|
||||
"TokenRefreshService",
|
||||
"TokenRefreshMiddleware",
|
||||
"ProactiveTokenRefreshMiddleware",
|
||||
# CSRF
|
||||
"CSRFMiddleware",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@
|
|||
"""
|
||||
Authentication module for backend API.
|
||||
Handles JWT-based authentication, token generation, and user context.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Token ist NICHT an einen Mandanten gebunden
|
||||
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
|
||||
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||
- Request-Context kapselt User + Mandant + Feature-Instanz + geladene Rollen
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from fastapi import Depends, HTTPException, status, Request, Response
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
from fastapi import Depends, HTTPException, status, Request, Response, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
import logging
|
||||
|
|
@ -15,9 +21,10 @@ from slowapi.util import get_remote_address
|
|||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.datamodels.datamodelUam import User, AuthAuthority
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.datamodels.datamodelRbac import AccessRule
|
||||
|
||||
# Get Config Data
|
||||
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
|
||||
|
|
@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
|||
if username is None:
|
||||
raise credentialsException
|
||||
|
||||
# Extract mandate ID and user ID from token
|
||||
mandateId: str = payload.get("mandateId")
|
||||
# Extract user ID from token
|
||||
# MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header
|
||||
userId: str = payload.get("userId")
|
||||
authority: str = payload.get("authenticationAuthority")
|
||||
tokenId: Optional[str] = payload.get("jti")
|
||||
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
|
||||
|
||||
if not mandateId or not userId:
|
||||
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
|
||||
# Only userId is required in token now (no mandateId)
|
||||
if not userId:
|
||||
logger.error(f"Missing userId in token")
|
||||
raise credentialsException
|
||||
|
||||
except JWTError:
|
||||
|
|
@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
|||
logger.warning(f"User {username} is disabled")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
|
||||
|
||||
# Ensure the user has the correct context
|
||||
if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId):
|
||||
logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})")
|
||||
# Ensure the user ID in token matches the user in database
|
||||
# MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers
|
||||
if str(user.id) != str(userId):
|
||||
logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User context has changed. Please log in again.",
|
||||
|
|
@ -166,17 +175,18 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
|||
db_token = db_tokens[0]
|
||||
token_authority = str(db_token.get("authority", "")).lower()
|
||||
if token_authority == str(AuthAuthority.LOCAL.value):
|
||||
# Must be active and match user/session/mandate
|
||||
# Must be active and match user/session
|
||||
# MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound
|
||||
active_token = appInterface.findActiveTokenById(
|
||||
tokenId=tokenId,
|
||||
userId=user.id,
|
||||
authority=AuthAuthority.LOCAL,
|
||||
sessionId=sessionId,
|
||||
mandateId=str(mandateId) if mandateId else None,
|
||||
mandateId=None, # Token is no longer mandate-bound
|
||||
)
|
||||
if not active_token:
|
||||
logger.info(
|
||||
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}"
|
||||
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
|
||||
)
|
||||
raise credentialsException
|
||||
else:
|
||||
|
|
@ -203,3 +213,183 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
|
|||
return currentUser
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MULTI-TENANT: Request Context System
|
||||
# =============================================================================
|
||||
|
||||
class RequestContext:
|
||||
"""
|
||||
Request context for multi-tenant operations.
|
||||
|
||||
Contains user, mandate context, feature instance context, and loaded role IDs.
|
||||
This context is per-request (not persisted) - follows stateless design.
|
||||
|
||||
IMPORTANT: SysAdmin also needs explicit membership for mandate context!
|
||||
isSysAdmin flag does NOT give implicit access to mandate data.
|
||||
"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user: User = user
|
||||
self.mandateId: Optional[str] = None
|
||||
self.featureInstanceId: Optional[str] = None
|
||||
self.roleIds: List[str] = []
|
||||
|
||||
# Request-scoped cache: rules loaded only once per request
|
||||
self._cachedRules: Optional[List[tuple]] = None
|
||||
|
||||
def getRules(self) -> List[tuple]:
|
||||
"""
|
||||
Loads rules once per request (not across requests).
|
||||
Returns list of (priority, AccessRule) tuples.
|
||||
"""
|
||||
if self._cachedRules is None:
|
||||
if not self.mandateId:
|
||||
# No mandate context = no rules
|
||||
self._cachedRules = []
|
||||
else:
|
||||
try:
|
||||
rootUser = getRootUser()
|
||||
appInterface = getInterface(rootUser)
|
||||
self._cachedRules = appInterface.rbac.getRulesForUserBulk(
|
||||
self.user.id,
|
||||
self.mandateId,
|
||||
self.featureInstanceId
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading RBAC rules: {e}")
|
||||
self._cachedRules = []
|
||||
return self._cachedRules
|
||||
|
||||
@property
|
||||
def isSysAdmin(self) -> bool:
|
||||
"""Convenience property to check if user is a system admin."""
|
||||
return getattr(self.user, 'isSysAdmin', False)
|
||||
|
||||
|
||||
def getRequestContext(
|
||||
request: Request,
|
||||
mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"),
|
||||
featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> RequestContext:
|
||||
"""
|
||||
Determines request context from headers.
|
||||
Checks authorization and loads role IDs.
|
||||
|
||||
Security Model:
|
||||
- Regular users: Must be explicit members of mandates/feature instances
|
||||
- SysAdmin users: Can access ANY mandate for administrative operations,
|
||||
but don't get implicit roleIds (no automatic data access rights).
|
||||
Routes can check ctx.isSysAdmin to allow admin operations.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
mandateId: Mandate ID from X-Mandate-Id header
|
||||
featureInstanceId: Feature instance ID from X-Instance-Id header
|
||||
currentUser: Current authenticated user
|
||||
|
||||
Returns:
|
||||
RequestContext with user, mandate, roles
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access
|
||||
"""
|
||||
ctx = RequestContext(user=currentUser)
|
||||
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
||||
|
||||
# Get root interface for membership checks
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
if mandateId:
|
||||
# Check mandate membership
|
||||
membership = rootInterface.getUserMandate(currentUser.id, mandateId)
|
||||
|
||||
if membership:
|
||||
# User is a member - load their roles
|
||||
if not membership.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate membership is disabled"
|
||||
)
|
||||
ctx.mandateId = mandateId
|
||||
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||
elif isSysAdmin:
|
||||
# SysAdmin can access any mandate for admin operations
|
||||
# But they don't get roleIds - no implicit data access
|
||||
ctx.mandateId = mandateId
|
||||
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization
|
||||
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership")
|
||||
else:
|
||||
# Regular user without membership - denied
|
||||
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not member of mandate"
|
||||
)
|
||||
|
||||
if featureInstanceId:
|
||||
# Check feature access
|
||||
access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId)
|
||||
|
||||
if access:
|
||||
# User has access - load their instance roles
|
||||
if not access.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Feature access is disabled"
|
||||
)
|
||||
ctx.featureInstanceId = featureInstanceId
|
||||
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
|
||||
ctx.roleIds.extend(instanceRoleIds)
|
||||
elif isSysAdmin:
|
||||
# SysAdmin can access any feature instance for admin operations
|
||||
ctx.featureInstanceId = featureInstanceId
|
||||
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access")
|
||||
else:
|
||||
# Regular user without access - denied
|
||||
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No access to feature instance"
|
||||
)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||
"""
|
||||
SysAdmin check for system-level operations.
|
||||
|
||||
Use this dependency for endpoints that require SysAdmin privileges.
|
||||
SysAdmin has access to system-level operations, but NOT to mandate data.
|
||||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
|
||||
Returns:
|
||||
User if they are a SysAdmin
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If user is not a SysAdmin
|
||||
"""
|
||||
if not getattr(currentUser, 'isSysAdmin', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="SysAdmin privileges required"
|
||||
)
|
||||
|
||||
# Audit for all SysAdmin actions
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
action="sysadmin_action",
|
||||
details="System-level operation"
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
pass
|
||||
|
||||
return currentUser
|
||||
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ class TokenManager:
|
|||
try:
|
||||
if interface is None:
|
||||
from modules.security.rootAccess import getRootUser
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
rootUser = getRootUser()
|
||||
interface = getInterface(rootUser)
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ class TokenRefreshService:
|
|||
|
||||
# Get user interface
|
||||
from modules.security.rootAccess import getRootUser
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
rootUser = getRootUser()
|
||||
root_interface = getInterface(rootUser)
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ class TokenRefreshService:
|
|||
|
||||
# Get user interface
|
||||
from modules.security.rootAccess import getRootUser
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
rootUser = getRootUser()
|
||||
root_interface = getInterface(rootUser)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,34 @@ class SystemTable(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
def _isJsonbType(fieldType) -> bool:
|
||||
"""Check if a type should be stored as JSONB in PostgreSQL."""
|
||||
# Direct dict or list
|
||||
if fieldType == dict or fieldType == list:
|
||||
return True
|
||||
|
||||
# Generic List[X] or Dict[X, Y]
|
||||
origin = get_origin(fieldType)
|
||||
if origin in (dict, list):
|
||||
return True
|
||||
|
||||
# Direct Pydantic BaseModel subclass
|
||||
if isinstance(fieldType, type) and issubclass(fieldType, BaseModel):
|
||||
return True
|
||||
|
||||
# Optional[X] - check the inner type
|
||||
if origin is Union:
|
||||
args = get_args(fieldType)
|
||||
for arg in args:
|
||||
if arg is type(None):
|
||||
continue
|
||||
# Recursively check the inner type
|
||||
if _isJsonbType(arg):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_model_fields(model_class) -> Dict[str, str]:
|
||||
"""Get all fields from Pydantic model and map to SQL types."""
|
||||
# Pydantic v2
|
||||
|
|
@ -52,20 +80,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
|||
|
||||
# Check for JSONB fields (Dict, List, or complex types)
|
||||
# Purely type-based detection - no hardcoded field names
|
||||
if (
|
||||
field_type == dict
|
||||
or field_type == list
|
||||
or (
|
||||
hasattr(field_type, "__origin__")
|
||||
and field_type.__origin__ in (dict, list)
|
||||
)
|
||||
# Check if field type is directly a Pydantic BaseModel subclass (for nested models like TextMultilingual)
|
||||
or (isinstance(field_type, type) and issubclass(field_type, BaseModel))
|
||||
# Check if field type is Optional[BaseModel] (Union with None)
|
||||
or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union
|
||||
and any(isinstance(arg, type) and issubclass(arg, BaseModel)
|
||||
for arg in get_args(field_type) if arg is not type(None)))
|
||||
):
|
||||
if _isJsonbType(field_type):
|
||||
fields[field_name] = "JSONB"
|
||||
# Simple type mapping
|
||||
elif field_type in (str, type(None)) or (
|
||||
|
|
@ -638,14 +653,12 @@ class DatabaseConnector:
|
|||
# Only set _createdBy if userId is valid (not None or empty string)
|
||||
if self.userId:
|
||||
record["_createdBy"] = self.userId
|
||||
else:
|
||||
logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set")
|
||||
# No warning - empty userId is normal during bootstrap
|
||||
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
|
||||
elif "_createdBy" not in record or not record.get("_createdBy"):
|
||||
if self.userId:
|
||||
record["_createdBy"] = self.userId
|
||||
else:
|
||||
logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}")
|
||||
# No warning - empty userId is normal during bootstrap
|
||||
# Always update modification metadata
|
||||
record["_modifiedAt"] = currentTime
|
||||
if self.userId:
|
||||
|
|
@ -855,8 +868,12 @@ class DatabaseConnector:
|
|||
|
||||
if recordFilter:
|
||||
for field, value in recordFilter.items():
|
||||
where_conditions.append(f'"{field}" = %s')
|
||||
where_values.append(value)
|
||||
if value is None:
|
||||
# Use IS NULL for None values (= NULL is always false in SQL)
|
||||
where_conditions.append(f'"{field}" IS NULL')
|
||||
else:
|
||||
where_conditions.append(f'"{field}" = %s')
|
||||
where_values.append(value)
|
||||
|
||||
# Build the query
|
||||
if where_conditions:
|
||||
|
|
@ -968,7 +985,10 @@ class DatabaseConnector:
|
|||
record["id"] = str(uuid.uuid4())
|
||||
|
||||
# Save record
|
||||
self._saveRecord(model_class, record["id"], record)
|
||||
success = self._saveRecord(model_class, record["id"], record)
|
||||
if not success:
|
||||
table = model_class.__name__
|
||||
raise ValueError(f"Failed to save record {record['id']} to table {table}")
|
||||
|
||||
# Check if this is the first record in the table and register as initial ID
|
||||
table = model_class.__name__
|
||||
|
|
|
|||
|
|
@ -1,314 +0,0 @@
|
|||
| Field Name | Type Pattern | Models Using It |
|
||||
|------------|--------------|-----------------|
|
||||
| `accumulatedJsonString` | str | JsonAccumulationState |
|
||||
| `action` | str | ActionDefinition |
|
||||
| `actionId` | str | ChatDocument, ChatMessage |
|
||||
| `actionList` | List | TaskItem |
|
||||
| `actionMethod` | str | ChatMessage |
|
||||
| `actionName` | str | ChatMessage |
|
||||
| `actionNumber` | int | ChatLog, ChatDocument, ChatMessage |
|
||||
| `actionObjective` | str | ActionDefinition, TaskContext |
|
||||
| `actionProgress` | str | ChatMessage |
|
||||
| `actionResult` | Any | TaskResult |
|
||||
| `active` | bool | AutomationDefinition |
|
||||
| `additionalData` | Dict | AiResponseMetadata |
|
||||
| `aiPrompt` | str | AiProcessParameters |
|
||||
| `allSections` | List | JsonAccumulationState |
|
||||
| `apiUrl` | str | AiModel |
|
||||
| `authenticationAuthority` | AuthAuthority | User |
|
||||
| `authority` | AuthAuthority | UserConnection, Token |
|
||||
| `availableConnections` | list | TaskContext |
|
||||
| `availableDocuments` | str | TaskContext |
|
||||
| `base64Encoded` | bool | ContentMetadata, FileData |
|
||||
| `bytesSent` | int | ChatStat, AiCallResponse |
|
||||
| `bytesReceived` | int | ChatStat, AiCallResponse |
|
||||
| `classes` | List | CodeContentPromptArgs |
|
||||
| `colorMode` | str | ContentMetadata |
|
||||
| `compressContext` | bool | AiCallOptions |
|
||||
| `compressPrompt` | bool | AiCallOptions |
|
||||
| `condition` | str | SelectionRule |
|
||||
| `confidence` | float | ReviewResult |
|
||||
| `connectedAt` | float | UserConnection |
|
||||
| `connectionId` | str | Token |
|
||||
| `connectionReference` | str | ActionDefinition |
|
||||
| `connectorType` | str | AiModel |
|
||||
| `content` | str | Prompt, ContentItem, AiResponse, AiCallResponse, FilePreview |
|
||||
| `contentAnalysis` | Dict | Observation |
|
||||
| `contentParts` | List | AiCallRequest, AiProcessParameters, SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
|
||||
| `contentSize` | str | ObservationPreview |
|
||||
| `contentValidation` | Dict | Observation |
|
||||
| `contents` | List | ChatContentExtracted |
|
||||
| `context` | Dict, AccessRuleContext | TaskHandover, UnderstandingResult, AccessRule |
|
||||
| `contextInfo` | str | CodeContentPromptArgs |
|
||||
| `costPer1kTokensInput` | float | AiModel |
|
||||
| `costPer1kTokensOutput` | float | AiModel |
|
||||
| `country` | str | AiCallPromptWebSearch |
|
||||
| `create` | AccessLevel | AccessRule, UserPermissions |
|
||||
| `created` | str | ObservationPreview |
|
||||
| `createdAt` | float | Token |
|
||||
| `creationDate` | float | FileItem |
|
||||
| `criteriaProgress` | dict | TaskContext |
|
||||
| `currentAction` | int | ChatWorkflow |
|
||||
| `currentRound` | int | ChatWorkflow |
|
||||
| `currentTask` | int | ChatWorkflow |
|
||||
| `data` | str | ContentItem, FileData |
|
||||
| `dataType` | str | TaskStep |
|
||||
| `delete` | AccessLevel | AccessRule, UserPermissions |
|
||||
| `deliverable` | Dict | TaskDefinition |
|
||||
| `delivered_summary` | str | ContinuationContext |
|
||||
| `dependencies` | List | TaskItem, TaskStep, CodeContentPromptArgs |
|
||||
| `description` | TextMultilingual | Role |
|
||||
| `details` | str | AuthEvent |
|
||||
| `detectedComplexity` | str | RequestContext |
|
||||
| `displayName` | str | AiModel |
|
||||
| `documentData` | Any | ActionDocument, DocumentData |
|
||||
| `documentId` | str | ObservationPreview |
|
||||
| `documentList` | DocumentReferenceList | ActionDefinition, ExtractContentParameters |
|
||||
| `documentName` | str | ActionDocument, DocumentData |
|
||||
| `documentReferences` | List | UnderstandingResult |
|
||||
| `documents` | List | ChatMessage, ActionResult, AiResponse |
|
||||
| `documentsCount` | int | Observation |
|
||||
| `documentsLabel` | str | ChatMessage, DocumentExchange |
|
||||
| `durationSec` | float | ContentMetadata |
|
||||
| `email` | EmailStr | User |
|
||||
| `enabled` | bool | Mandate, User |
|
||||
| `encoding` | str | FilePreview |
|
||||
| `engine` | str | ChatStat |
|
||||
| `error` | str | ContentMetadata, ActionResult, ActionItem, TaskItem, TaskResult |
|
||||
| `errorCount` | int | ChatStat, AiCallResponse |
|
||||
| `estimatedComplexity` | str | TaskStep |
|
||||
| `eventId` | str | AutomationDefinition |
|
||||
| `eventType` | str | AuthEvent |
|
||||
| `execAction` | str | ActionItem |
|
||||
| `execMethod` | str | ActionItem |
|
||||
| `execParameters` | Dict | ActionItem |
|
||||
| `execResultLabel` | str | ActionItem |
|
||||
| `executedActions` | list | TaskContext |
|
||||
| `executionLogs` | List | AutomationDefinition |
|
||||
| `expectedDocumentFormats` | List | ActionItem |
|
||||
| `expectedFormats` | List | ChatWorkflow, TaskStep |
|
||||
| `expectedOutputFormat` | str | RequestContext |
|
||||
| `expectedOutputType` | str | RequestContext |
|
||||
| `expiresAt` | float | UserConnection, Token |
|
||||
| `externalEmail` | EmailStr | UserConnection |
|
||||
| `externalId` | str | UserConnection |
|
||||
| `externalUsername` | str | UserConnection |
|
||||
| `extractionMethod` | str | AiResponseMetadata |
|
||||
| `extractionOptions` | Any | TaskDefinition, ExtractContentParameters |
|
||||
| `failedActions` | list | TaskContext |
|
||||
| `failurePatterns` | list | TaskContext |
|
||||
| `feedback` | str | TaskResult, TaskItem |
|
||||
| `fileHash` | str | FileItem |
|
||||
| `fileId` | str | ChatDocument |
|
||||
| `fileName` | str | FileItem, FilePreview, ChatDocument |
|
||||
| `fileSize` | int | FileItem, ChatDocument |
|
||||
| `fileType` | str | CodeContentPromptArgs |
|
||||
| `filename` | str | AiResponseMetadata, CodeContentPromptArgs |
|
||||
| `finishedAt` | float | TaskItem |
|
||||
| `fps` | float | ContentMetadata |
|
||||
| `fullName` | str | User |
|
||||
| `functionCall` | Callable | AiModel |
|
||||
| `functions` | List | CodeContentPromptArgs |
|
||||
| `generationHint` | str | SectionPromptArgs |
|
||||
| `handoverType` | str | TaskHandover |
|
||||
| `hashedPassword` | str | UserInDB |
|
||||
| `height` | int | ContentMetadata |
|
||||
| `hierarchyContext` | str | JsonContinuationContexts |
|
||||
| `hierarchyContextForPrompt` | str | JsonContinuationContexts |
|
||||
| `id` | str | *Most models* |
|
||||
| `improvements` | List | TaskHandover, TaskContext, ReviewResult |
|
||||
| `incomplete_part` | str | ContinuationContext |
|
||||
| `inputDocuments` | List | TaskHandover |
|
||||
| `instruction` | str | AiCallPromptWebSearch, AiCallPromptWebCrawl |
|
||||
| `intention` | Dict | UnderstandingResult |
|
||||
| `ipAddress` | str | AuthEvent |
|
||||
| `isAccumulationMode` | bool | JsonAccumulationState |
|
||||
| `isAggregation` | bool | SectionPromptArgs |
|
||||
| `isAvailable` | bool | AiModel |
|
||||
| `isRegeneration` | bool | TaskContext |
|
||||
| `isSystemRole` | bool | Role |
|
||||
| `isText` | bool | FilePreview |
|
||||
| `item` | str | AccessRule |
|
||||
| `jsonParsingSuccess` | bool | JsonContinuationContexts |
|
||||
| `kpis` | List | JsonAccumulationState |
|
||||
| `label` | str | ContentItem, AutomationDefinition |
|
||||
| `language` | str | Mandate, User, SectionPromptArgs, AiCallPromptWebSearch |
|
||||
| `last_complete_part` | str | ContinuationContext |
|
||||
| `last_raw_json` | str | ContinuationContext |
|
||||
| `lastActivity` | float | ChatWorkflow |
|
||||
| `lastChecked` | float | UserConnection |
|
||||
| `lastParsedResult` | Dict | JsonAccumulationState |
|
||||
| `lastUpdated` | str | AiModel |
|
||||
| `learnings` | List | ActionDefinition, TaskContext |
|
||||
| `listFileId` | List | UserInputRequest |
|
||||
| `logs` | List | ChatWorkflow |
|
||||
| `mandateId` | str | ChatWorkflow, FileItem, Prompt, User, AutomationDefinition, Token |
|
||||
| `maxCost` | float | SelectionRule, AiCallOptions |
|
||||
| `maxDepth` | int | AiCallPromptWebCrawl |
|
||||
| `maxNumberPages` | int | AiCallPromptWebSearch |
|
||||
| `maxParts` | int | AiCallOptions |
|
||||
| `maxProcessingTime` | int | AiCallOptions |
|
||||
| `maxSteps` | int | ChatWorkflow |
|
||||
| `maxTokens` | int | AiModel |
|
||||
| `maxWidth` | int | AiCallPromptWebCrawl |
|
||||
| `message` | str | ChatLog, ChatMessage |
|
||||
| `messageHistory` | List | TaskHandover |
|
||||
| `messageId` | str | ChatDocument |
|
||||
| `messages` | List | ChatWorkflow, AiModelCall |
|
||||
| `metadata` | ContentMetadata, AiResponseMetadata, Dict | ContentItem, AiResponse, AiModelResponse, CodeContentPromptArgs |
|
||||
| `metCriteria` | List | ReviewResult |
|
||||
| `method` | str | ActionSelection |
|
||||
| `mime` | str | ObservationPreview |
|
||||
| `mimeType` | str | ContentMetadata, FileItem, FilePreview, ChatDocument, ActionDocument, DocumentData |
|
||||
| `minContextLength` | int | AiModel, SelectionRule |
|
||||
| `minQualityRating` | int | SelectionRule |
|
||||
| `missingOutputs` | List | ReviewResult |
|
||||
| `model` | AiModel | AiModelCall |
|
||||
| `modelId` | str | AiModelResponse |
|
||||
| `modelName` | str | AiCallResponse |
|
||||
| `modified` | str | ObservationPreview |
|
||||
| `name` | str | Mandate, Prompt, ActionSelection, AiModel, SelectionRule, ObservationPreview |
|
||||
| `nextAction` | str | ReviewResult |
|
||||
| `nextActionGuidance` | Dict | TaskContext |
|
||||
| `nextActionObjective` | str | ReviewResult |
|
||||
| `nextActionParameters` | Dict | ReviewResult |
|
||||
| `notes` | List | Observation |
|
||||
| `objective` | str | TaskStep, TaskDefinition |
|
||||
| `operationId` | str | ChatLog |
|
||||
| `operationType` | OperationTypeEnum, str | OperationTypeRating, AiCallOptions, AiResponseMetadata |
|
||||
| `operationTypes` | List | AiModel, SelectionRule |
|
||||
| `options` | AiCallOptions | AiCallRequest, AiModelCall |
|
||||
| `originalPrompt` | str | RequestContext |
|
||||
| `outputDocuments` | List | TaskHandover |
|
||||
| `outputFormat` | str | ChapterStructurePromptArgs |
|
||||
| `overlapContext` | str | JsonContinuationContexts |
|
||||
| `overlap_context` | str | ContinuationContext |
|
||||
| `overview` | str | TaskPlan |
|
||||
| `pages` | int | ContentMetadata |
|
||||
| `parameters` | Dict | ActionParameters, UnderstandingResult, ActionDefinition |
|
||||
| `parametersContext` | str | ActionDefinition, TaskContext |
|
||||
| `parentId` | str | ChatLog |
|
||||
| `parentMessageId` | str | ChatMessage |
|
||||
| `performance` | Dict | ChatLog |
|
||||
| `placeholders` | List, Dict | PromptBundle, AutomationDefinition |
|
||||
| `priceUsd` | float | ChatStat, AiCallResponse |
|
||||
| `previews` | List | Observation |
|
||||
| `previousActionResults` | list | TaskContext |
|
||||
| `previousHandover` | TaskHandover | TaskContext |
|
||||
| `previousResults` | List | TaskHandover, TaskContext, ReviewContext |
|
||||
| `previousReviewResult` | dict | TaskContext |
|
||||
| `priority` | PriorityEnum | AiModel, SelectionRule, AiCallOptions |
|
||||
| `process` | str | ChatStat |
|
||||
| `processDocumentsIndividually` | bool | AiCallOptions |
|
||||
| `processingMode` | ProcessingModeEnum | AiModel, AiCallOptions |
|
||||
| `processingTime` | float | ChatStat, ActionItem, TaskItem, AiCallResponse, AiModelResponse |
|
||||
| `progress` | float | ChatLog |
|
||||
| `prompt` | str | UserInputRequest, PromptBundle, AiCallPromptImage |
|
||||
| `publishedAt` | float | ChatMessage |
|
||||
| `qualityRating` | int | AiModel |
|
||||
| `qualityRequirements` | Dict | TaskStep |
|
||||
| `qualityScore` | float | ReviewResult |
|
||||
| `quality` | str | AiCallPromptImage |
|
||||
| `rating` | int | OperationTypeRating |
|
||||
| `read` | AccessLevel | AccessRule, UserPermissions |
|
||||
| `reason` | str | Token, ReviewResult |
|
||||
| `reference` | str | ObservationPreview |
|
||||
| `requiredDocuments` | List | TaskDefinition |
|
||||
| `requiresAnalysis` | bool | RequestContext |
|
||||
| `requiresContentGeneration` | bool | TaskDefinition |
|
||||
| `requiresDocumentAnalysis` | bool | TaskDefinition |
|
||||
| `requiresDocuments` | bool | RequestContext |
|
||||
| `requiresWebResearch` | bool | RequestContext, TaskDefinition |
|
||||
| `researchDepth` | str | AiCallPromptWebSearch |
|
||||
| `resetToken` | str | UserInDB |
|
||||
| `resetTokenExpires` | float | UserInDB |
|
||||
| `result` | str | ActionItem |
|
||||
| `resultFormat` | str | AiCallOptions |
|
||||
| `resultLabel` | str | ActionResult, Observation |
|
||||
| `resultLabels` | Dict | TaskItem |
|
||||
| `resultType` | str | AiProcessParameters |
|
||||
| `retryCount` | int | ActionItem, TaskItem, TaskContext |
|
||||
| `retryMax` | int | ActionItem, TaskItem |
|
||||
| `revokedAt` | float | Token |
|
||||
| `revokedBy` | str | Token |
|
||||
| `role` | str | ChatMessage |
|
||||
| `roleLabel` | str | Role, AccessRule |
|
||||
| `roleLabels` | List | User |
|
||||
| `rollbackOnFailure` | bool | TaskItem |
|
||||
| `roundNumber` | int | ChatLog, ChatDocument, ChatMessage |
|
||||
| `safetyMargin` | float | AiCallOptions |
|
||||
| `schedule` | str | AutomationDefinition |
|
||||
| `schemaVersion` | str | AiResponseMetadata |
|
||||
| `section` | Dict | SectionPromptArgs |
|
||||
| `section_count` | int | ContinuationContext |
|
||||
| `sectionIndex` | int | SectionPromptArgs |
|
||||
| `sequenceNr` | int | ChatMessage |
|
||||
| `sessionId` | str | Token |
|
||||
| `size` | int, str | ContentMetadata, FilePreview, AiCallPromptImage, ObservationPreview |
|
||||
| `snippet` | str | ObservationPreview |
|
||||
| `sourceDocuments` | List | AiResponseMetadata |
|
||||
| `sourceJson` | Dict | ActionDocument, DocumentData |
|
||||
| `sourceTask` | str | TaskHandover |
|
||||
| `speedRating` | int | AiModel |
|
||||
| `stage1Selection` | dict | TaskContext |
|
||||
| `startedAt` | float | ChatWorkflow, TaskItem |
|
||||
| `stats` | List | ChatWorkflow |
|
||||
| `status` | str, TokenStatus, TaskStatus, ConnectionStatus | ChatLog, ChatWorkflow, ChatMessage, UserConnection, AutomationDefinition, ActionItem, TaskItem, TaskResult, ReviewResult, Token |
|
||||
| `style` | str | AiCallPromptImage |
|
||||
| `success` | bool | ChatMessage, ActionResult, Observation, TaskResult, AuthEvent, AiModelResponse |
|
||||
| `successCriteria` | list | TaskStep |
|
||||
| `successfulActions` | list | TaskContext |
|
||||
| `summary` | str | ChatMessage |
|
||||
| `summaryAllowed` | bool | PromptPlaceholder |
|
||||
| `taskActions` | list | ReviewContext |
|
||||
| `taskId` | str | TaskResult, TaskHandover |
|
||||
| `taskNumber` | int | ChatLog, ChatDocument, ChatMessage |
|
||||
| `taskProgress` | str | ChatMessage |
|
||||
| `tasks` | List | ChatWorkflow, TaskPlan, UnderstandingResult |
|
||||
| `taskStep` | TaskStep | TaskContext, ReviewContext |
|
||||
| `temperature` | float | AiModel, AiCallOptions |
|
||||
| `template` | str | AutomationDefinition |
|
||||
| `template_structure` | str | ContinuationContext |
|
||||
| `timestamp` | float | ChatLog, ActionItem, TaskHandover, AuthEvent |
|
||||
| `title` | str | AiResponseMetadata |
|
||||
| `tokenAccess` | str | Token |
|
||||
| `tokenExpiresAt` | float | UserConnection |
|
||||
| `tokenRefresh` | str | Token |
|
||||
| `tokensUsed` | Dict | AiModelResponse |
|
||||
| `tokenStatus` | str | UserConnection |
|
||||
| `tokenType` | str | Token |
|
||||
| `totalActions` | int | ChatWorkflow |
|
||||
| `totalTasks` | int | ChatWorkflow |
|
||||
| `type` | str | ChatLog |
|
||||
| `typeGroup` | str | ObservationPreview |
|
||||
| `unmetCriteria` | List | ReviewResult |
|
||||
| `update` | AccessLevel | AccessRule, UserPermissions |
|
||||
| `url` | str | AiCallPromptWebCrawl |
|
||||
| `userAgent` | str | AuthEvent |
|
||||
| `userId` | str | UserConnection, Token, AuthEvent |
|
||||
| `userInput` | str | TaskItem |
|
||||
| `userLanguage` | str | UserInputRequest, RequestContext |
|
||||
| `userMessage` | str | ActionItem, TaskStep, ReviewResult, TaskPlan, ActionDefinition |
|
||||
| `userPrompt` | str | SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
|
||||
| `username` | str | User |
|
||||
| `validationMetadata` | Dict | ActionDocument |
|
||||
| `version` | str | AiModel |
|
||||
| `view` | bool | AccessRule, UserPermissions |
|
||||
| `weight` | float | SelectionRule |
|
||||
| `width` | int | ContentMetadata |
|
||||
| `workflow` | ChatWorkflow | TaskContext |
|
||||
| `workflowId` | str | ChatStat, ChatLog, ChatMessage, ChatWorkflow, TaskItem, TaskContext, ReviewContext |
|
||||
| `workflowMode` | WorkflowModeEnum | ChatWorkflow |
|
||||
| `workflowSummary` | str | TaskHandover |
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
Can you adapt following fields to Multilingual Fields (`TextMultilingual`):
|
||||
|
||||
| Field Name | Models |
|
||||
|------------|--------|
|
||||
| `description` | Role | (is already)
|
||||
|
||||
|
||||
|
|
@ -10,7 +10,6 @@ Usage examples:
|
|||
from . import datamodelAi as ai
|
||||
from . import datamodelUam as uam
|
||||
from . import datamodelSecurity as security
|
||||
from . import datamodelNeutralizer as neutralizer
|
||||
from . import datamodelChat as chat
|
||||
from . import datamodelFiles as files
|
||||
from . import datamodelVoice as voice
|
||||
|
|
|
|||
208
modules/datamodels/datamodelAudit.py
Normal file
208
modules/datamodels/datamodelAudit.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Audit Log Data Model for database-based audit logging.
|
||||
|
||||
This model stores security-relevant audit events for GDPR compliance and security monitoring.
|
||||
|
||||
GDPR-Relevant Events:
|
||||
- User access: login, logout, failed login attempts
|
||||
- Data access: create, read, update, delete operations on personal data
|
||||
- Security events: password changes, token refresh, session management
|
||||
- Key access: encryption/decryption of sensitive data
|
||||
- GDPR actions: data export, data portability, account deletion
|
||||
- Mandate/permission changes: user added/removed from mandates, role changes
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
import uuid
|
||||
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
|
||||
|
||||
class AuditCategory(str, Enum):
|
||||
"""Categories for audit log entries"""
|
||||
ACCESS = "access" # Login/logout events
|
||||
KEY = "key" # Encryption key access
|
||||
DATA = "data" # Data CRUD operations
|
||||
SECURITY = "security" # Security-related events
|
||||
GDPR = "gdpr" # GDPR-specific actions
|
||||
PERMISSION = "permission" # Permission/role changes
|
||||
SYSTEM = "system" # System-level events
|
||||
|
||||
|
||||
class AuditAction(str, Enum):
|
||||
"""Actions for audit log entries"""
|
||||
# Access actions
|
||||
LOGIN = "login"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
LOGOUT = "logout"
|
||||
TOKEN_REFRESH = "token_refresh"
|
||||
TOKEN_REVOKE = "token_revoke"
|
||||
SESSION_EXPIRED = "session_expired"
|
||||
|
||||
# Key actions
|
||||
KEY_ENCODE = "encode"
|
||||
KEY_DECODE = "decode"
|
||||
KEY_ACCESS = "key_access"
|
||||
|
||||
# Data actions
|
||||
DATA_CREATE = "create"
|
||||
DATA_READ = "read"
|
||||
DATA_UPDATE = "update"
|
||||
DATA_DELETE = "delete"
|
||||
DATA_EXPORT = "export"
|
||||
|
||||
# Security actions
|
||||
PASSWORD_CHANGE = "password_change"
|
||||
PASSWORD_RESET = "password_reset"
|
||||
MFA_ENABLED = "mfa_enabled"
|
||||
MFA_DISABLED = "mfa_disabled"
|
||||
|
||||
# GDPR actions
|
||||
GDPR_DATA_EXPORT = "gdpr_data_export"
|
||||
GDPR_DATA_PORTABILITY = "gdpr_data_portability"
|
||||
GDPR_ACCOUNT_DELETION = "gdpr_account_deletion"
|
||||
GDPR_CONSENT_UPDATE = "gdpr_consent_update"
|
||||
|
||||
# Permission actions
|
||||
USER_ADDED_TO_MANDATE = "user_added_to_mandate"
|
||||
USER_REMOVED_FROM_MANDATE = "user_removed_from_mandate"
|
||||
ROLE_ASSIGNED = "role_assigned"
|
||||
ROLE_REVOKED = "role_revoked"
|
||||
FEATURE_ACCESS_GRANTED = "feature_access_granted"
|
||||
FEATURE_ACCESS_REVOKED = "feature_access_revoked"
|
||||
|
||||
# System actions
|
||||
SYSTEM_STARTUP = "system_startup"
|
||||
SYSTEM_SHUTDOWN = "system_shutdown"
|
||||
CONFIG_CHANGE = "config_change"
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
"""
|
||||
Audit log entry for database storage.
|
||||
|
||||
Stores all security-relevant events for compliance and monitoring.
|
||||
Entries are immutable once created (append-only audit trail).
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique identifier for the audit entry",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
timestamp: float = Field(
|
||||
default_factory=getUtcTimestamp,
|
||||
description="UTC timestamp when the event occurred",
|
||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
# Actor identification
|
||||
userId: str = Field(
|
||||
description="ID of the user who performed the action (or 'system' for system events)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
username: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Username at the time of the event (for historical reference)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Context
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mandate context (if applicable)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature instance context (if applicable)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Event classification
|
||||
category: str = Field(
|
||||
description="Event category (access, key, data, security, gdpr, permission, system)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
action: str = Field(
|
||||
description="Specific action performed",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
# Event details
|
||||
resourceType: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
resourceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID of the affected resource",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
details: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Additional details about the event",
|
||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Request metadata
|
||||
ipAddress: Optional[str] = Field(
|
||||
default=None,
|
||||
description="IP address of the client",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
userAgent: Optional[str] = Field(
|
||||
default=None,
|
||||
description="User agent string from the request",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Outcome
|
||||
success: bool = Field(
|
||||
default=True,
|
||||
description="Whether the action was successful",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
errorMessage: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Error message if the action failed",
|
||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
# Register labels for internationalization
|
||||
registerModelLabels(
|
||||
"AuditLogEntry",
|
||||
{"en": "Audit Log Entry", "de": "Audit-Log-Eintrag", "fr": "Entrée du journal d'audit"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
|
||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
|
||||
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID de l'instance"},
|
||||
"category": {"en": "Category", "de": "Kategorie", "fr": "Catégorie"},
|
||||
"action": {"en": "Action", "de": "Aktion", "fr": "Action"},
|
||||
"resourceType": {"en": "Resource Type", "de": "Ressourcentyp", "fr": "Type de ressource"},
|
||||
"resourceId": {"en": "Resource ID", "de": "Ressourcen-ID", "fr": "ID de ressource"},
|
||||
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
|
||||
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
|
||||
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
|
||||
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
|
||||
"errorMessage": {"en": "Error Message", "de": "Fehlermeldung", "fr": "Message d'erreur"},
|
||||
},
|
||||
)
|
||||
|
|
@ -11,6 +11,7 @@ import uuid
|
|||
|
||||
|
||||
class ChatStat(BaseModel):
|
||||
"""Statistics for chat operations. User-owned, no mandate context."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
|
|
@ -46,6 +47,7 @@ registerModelLabels(
|
|||
|
||||
|
||||
class ChatLog(BaseModel):
|
||||
"""Log entries for chat workflows. User-owned, no mandate context."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
|
|
@ -91,6 +93,7 @@ registerModelLabels(
|
|||
|
||||
|
||||
class ChatDocument(BaseModel):
|
||||
"""Documents attached to chat messages. User-owned, no mandate context."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
|
|
@ -197,6 +200,7 @@ registerModelLabels(
|
|||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""Messages in chat workflows. User-owned, no mandate context."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||
)
|
||||
|
|
@ -216,11 +220,12 @@ class ChatMessage(BaseModel):
|
|||
)
|
||||
role: str = Field(description="Role of the message sender")
|
||||
status: str = Field(description="Status of the message (first, step, last)")
|
||||
sequenceNr: int = Field(
|
||||
sequenceNr: Optional[int] = Field(
|
||||
default=0,
|
||||
description="Sequence number of the message (set automatically)"
|
||||
)
|
||||
publishedAt: float = Field(
|
||||
default_factory=getUtcTimestamp,
|
||||
publishedAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When the message was published (UTC timestamp in seconds)",
|
||||
)
|
||||
success: Optional[bool] = Field(
|
||||
|
|
@ -294,8 +299,8 @@ registerModelLabels(
|
|||
|
||||
|
||||
class ChatWorkflow(BaseModel):
|
||||
"""Chat workflow container. User-owned, no mandate context."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "running", "label": {"en": "Running", "fr": "En cours"}},
|
||||
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
|
||||
|
|
@ -369,7 +374,6 @@ registerModelLabels(
|
|||
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"name": {"en": "Name", "fr": "Nom"},
|
||||
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},
|
||||
|
|
@ -988,38 +992,3 @@ registerModelLabels(
|
|||
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AutomationDefinition(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
|
||||
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
|
||||
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
|
||||
{"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}},
|
||||
{"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}}
|
||||
]})
|
||||
template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True})
|
||||
placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "text"})
|
||||
active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False})
|
||||
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"AutomationDefinition",
|
||||
{"en": "Automation Definition", "fr": "Définition d'automatisation"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"label": {"en": "Label", "fr": "Libellé"},
|
||||
"schedule": {"en": "Schedule", "fr": "Planification"},
|
||||
"template": {"en": "Template", "fr": "Modèle"},
|
||||
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
|
||||
"active": {"en": "Active", "fr": "Actif"},
|
||||
"eventId": {"en": "Event ID", "fr": "ID de l'événement"},
|
||||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
83
modules/datamodels/datamodelFeatures.py
Normal file
83
modules/datamodels/datamodelFeatures.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Feature models: Feature, FeatureInstance."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||
|
||||
|
||||
class Feature(BaseModel):
|
||||
"""
|
||||
Feature-Definition (global, z.B. 'trustee', 'chatbot').
|
||||
Features sind die verfügbaren Funktionalitäten der Plattform.
|
||||
"""
|
||||
code: str = Field(
|
||||
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
label: TextMultilingual = Field(
|
||||
description="Feature label in multiple languages (I18n)",
|
||||
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
icon: str = Field(
|
||||
default="",
|
||||
description="Icon identifier for the feature",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"Feature",
|
||||
{"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
|
||||
{
|
||||
"code": {"en": "Code", "de": "Code", "fr": "Code"},
|
||||
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
|
||||
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FeatureInstance(BaseModel):
|
||||
"""
|
||||
Instanz eines Features in einem Mandanten.
|
||||
Ein Mandant kann mehrere Instanzen desselben Features haben.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the feature instance",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
featureCode: str = Field(
|
||||
description="FK → Feature.code",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="FK → Mandate.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
label: str = Field(
|
||||
default="",
|
||||
description="Instance label, z.B. 'Buchhaltung 2025'",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether this feature instance is enabled",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"FeatureInstance",
|
||||
{"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
|
||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
},
|
||||
)
|
||||
|
|
@ -12,7 +12,8 @@ import base64
|
|||
|
||||
class FileItem(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
|
||||
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
|
|
@ -25,6 +26,7 @@ registerModelLabels(
|
|||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
|
||||
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
|
||||
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
|
||||
|
|
|
|||
133
modules/datamodels/datamodelInvitation.py
Normal file
133
modules/datamodels/datamodelInvitation.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Invitation model for self-service onboarding.
|
||||
Token-basierte Einladungen für neue User zu Mandanten/Features.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import secrets
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
|
||||
class Invitation(BaseModel):
|
||||
"""
|
||||
Einladungs-Token für neue User.
|
||||
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the invitation",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
token: str = Field(
|
||||
default_factory=lambda: secrets.token_urlsafe(32),
|
||||
description="Secure invitation token",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Ziel der Einladung
|
||||
mandateId: str = Field(
|
||||
description="FK → Mandate.id - Target mandate for the invitation",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
roleIds: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of Role IDs to assign to the invited user",
|
||||
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
|
||||
# Einladungs-Details
|
||||
targetUsername: str = Field(
|
||||
description="Username of the invited user (must match on acceptance)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
email: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Email address to send invitation link (optional)",
|
||||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
createdBy: str = Field(
|
||||
description="User ID of the person who created the invitation",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
createdAt: float = Field(
|
||||
default_factory=getUtcTimestamp,
|
||||
description="When the invitation was created (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
expiresAt: float = Field(
|
||||
description="When the invitation expires (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
# Status
|
||||
usedBy: Optional[str] = Field(
|
||||
default=None,
|
||||
description="User ID of the person who used the invitation",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
usedAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When the invitation was used (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
revokedAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When the invitation was revoked (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Email-Status
|
||||
emailSent: bool = Field(
|
||||
default=False,
|
||||
description="Whether the invitation email was successfully sent",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Einschränkungen
|
||||
maxUses: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum number of times this invitation can be used",
|
||||
json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
currentUses: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Current number of times this invitation has been used",
|
||||
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"Invitation",
|
||||
{"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"token": {"en": "Token", "de": "Token", "fr": "Jeton"},
|
||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
||||
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
|
||||
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
|
||||
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
|
||||
"createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
|
||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
||||
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
|
||||
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
|
||||
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
|
||||
"emailSent": {"en": "Email Sent", "de": "E-Mail gesendet", "fr": "Email envoyé"},
|
||||
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
|
||||
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
|
||||
},
|
||||
)
|
||||
150
modules/datamodels/datamodelMembership.py
Normal file
150
modules/datamodels/datamodelMembership.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Membership models: UserMandate, FeatureAccess, and Junction Tables.
|
||||
|
||||
Diese Models definieren die m:n Beziehungen zwischen User, Mandate und FeatureInstance.
|
||||
Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
|
||||
|
||||
class UserMandate(BaseModel):
|
||||
"""
|
||||
User-Mitgliedschaft in einem Mandanten.
|
||||
Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the user-mandate membership",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
userId: str = Field(
|
||||
description="FK → User.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="FK → Mandate.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether this membership is enabled",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
# Rollen werden via Junction Table UserMandateRole verknüpft
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"UserMandate",
|
||||
{"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FeatureAccess(BaseModel):
|
||||
"""
|
||||
User-Zugriff auf eine Feature-Instanz.
|
||||
Definiert welche User auf welche Feature-Instanzen zugreifen können.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the feature access",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
userId: str = Field(
|
||||
description="FK → User.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="FK → FeatureInstance.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether this feature access is enabled",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
# Rollen werden via Junction Table FeatureAccessRole verknüpft
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"FeatureAccess",
|
||||
{"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserMandateRole(BaseModel):
|
||||
"""
|
||||
Junction Table: UserMandate zu Role.
|
||||
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the junction record",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
userMandateId: str = Field(
|
||||
description="FK → UserMandate.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
|
||||
)
|
||||
roleId: str = Field(
|
||||
description="FK → Role.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"UserMandateRole",
|
||||
{"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
|
||||
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FeatureAccessRole(BaseModel):
|
||||
"""
|
||||
Junction Table: FeatureAccess zu Role.
|
||||
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the junction record",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
featureAccessId: str = Field(
|
||||
description="FK → FeatureAccess.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
|
||||
)
|
||||
roleId: str = Field(
|
||||
description="FK → Role.id (CASCADE DELETE)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"FeatureAccessRole",
|
||||
{"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
|
||||
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||
},
|
||||
)
|
||||
|
|
@ -45,6 +45,10 @@ class MessagingSubscription(BaseModel):
|
|||
description="ID of the mandate this subscription belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance this subscription belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Description of the subscription",
|
||||
|
|
@ -92,6 +96,7 @@ registerModelLabels(
|
|||
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
|
||||
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"description": {"en": "Description", "fr": "Description"},
|
||||
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||
|
|
@ -110,6 +115,14 @@ class MessagingSubscriptionRegistration(BaseModel):
|
|||
description="Unique ID of the registration",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="ID of the mandate this registration belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance this registration belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
subscriptionId: str = Field(
|
||||
description="ID of the subscription this registration belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
|
|
@ -161,6 +174,8 @@ registerModelLabels(
|
|||
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"channel": {"en": "Channel", "fr": "Canal"},
|
||||
|
|
@ -179,6 +194,14 @@ class MessagingDelivery(BaseModel):
|
|||
description="Unique ID of the delivery",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
mandateId: str = Field(
|
||||
description="ID of the mandate this delivery belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance this delivery belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
subscriptionId: str = Field(
|
||||
description="ID of the subscription this delivery belongs to",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
|
|
@ -239,6 +262,8 @@ registerModelLabels(
|
|||
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"channel": {"en": "Channel", "fr": "Canal"},
|
||||
|
|
|
|||
209
modules/datamodels/datamodelNotification.py
Normal file
209
modules/datamodels/datamodelNotification.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Notification model for in-app notifications.
|
||||
Supports actionable notifications (e.g., invitation accept/decline).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""Types of notifications"""
|
||||
INVITATION = "invitation" # Einladung zu Mandat/Feature
|
||||
SYSTEM = "system" # System-Nachrichten
|
||||
WORKFLOW = "workflow" # Workflow-Status Updates
|
||||
MENTION = "mention" # Erwähnung in Chat/Kommentar
|
||||
|
||||
|
||||
class NotificationStatus(str, Enum):
|
||||
"""Status of a notification"""
|
||||
UNREAD = "unread" # Noch nicht gelesen
|
||||
READ = "read" # Gelesen
|
||||
ACTIONED = "actioned" # Aktion wurde durchgeführt
|
||||
DISMISSED = "dismissed" # Verworfen/Geschlossen
|
||||
|
||||
|
||||
class NotificationAction(BaseModel):
|
||||
"""Possible action for a notification"""
|
||||
actionId: str = Field(
|
||||
description="Unique identifier for the action (e.g., 'accept', 'decline')"
|
||||
)
|
||||
label: str = Field(
|
||||
description="Display label for the action button"
|
||||
)
|
||||
style: str = Field(
|
||||
default="default",
|
||||
description="Button style: 'primary', 'danger', 'default'"
|
||||
)
|
||||
|
||||
|
||||
class UserNotification(BaseModel):
|
||||
"""
|
||||
In-app notification for a user.
|
||||
Supports actionable notifications with accept/decline buttons.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the notification",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
userId: str = Field(
|
||||
description="Target user ID for this notification",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
|
||||
# Notification type and status
|
||||
type: NotificationType = Field(
|
||||
default=NotificationType.SYSTEM,
|
||||
description="Type of notification",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": True,
|
||||
"frontend_options": [
|
||||
{"value": "invitation", "label": {"en": "Invitation", "de": "Einladung"}},
|
||||
{"value": "system", "label": {"en": "System", "de": "System"}},
|
||||
{"value": "workflow", "label": {"en": "Workflow", "de": "Workflow"}},
|
||||
{"value": "mention", "label": {"en": "Mention", "de": "Erwähnung"}}
|
||||
]
|
||||
}
|
||||
)
|
||||
status: NotificationStatus = Field(
|
||||
default=NotificationStatus.UNREAD,
|
||||
description="Current status of the notification",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"frontend_options": [
|
||||
{"value": "unread", "label": {"en": "Unread", "de": "Ungelesen"}},
|
||||
{"value": "read", "label": {"en": "Read", "de": "Gelesen"}},
|
||||
{"value": "actioned", "label": {"en": "Actioned", "de": "Bearbeitet"}},
|
||||
{"value": "dismissed", "label": {"en": "Dismissed", "de": "Verworfen"}}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Content
|
||||
title: str = Field(
|
||||
description="Notification title",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
message: str = Field(
|
||||
description="Notification message/body",
|
||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
icon: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional icon identifier (e.g., 'mail', 'warning', 'info')",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Reference to triggering object (for actionable notifications)
|
||||
referenceType: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Type of referenced object (e.g., 'Invitation', 'Workflow')",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
referenceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID of referenced object",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Actions (for actionable notifications like invitations)
|
||||
actions: Optional[List[NotificationAction]] = Field(
|
||||
default=None,
|
||||
description="List of possible actions for this notification",
|
||||
json_schema_extra={"frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Action result (when user takes action)
|
||||
actionTaken: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Which action was taken (actionId)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
actionResult: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Result message from the action",
|
||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
createdAt: float = Field(
|
||||
default_factory=getUtcTimestamp,
|
||||
description="When the notification was created (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
readAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When the notification was read (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
actionedAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When action was taken (UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
expiresAt: Optional[float] = Field(
|
||||
default=None,
|
||||
description="When the notification expires (optional, UTC timestamp)",
|
||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"UserNotification",
|
||||
{"en": "Notification", "de": "Benachrichtigung", "fr": "Notification"},
|
||||
{
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||
"type": {"en": "Type", "de": "Typ", "fr": "Type"},
|
||||
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||
"title": {"en": "Title", "de": "Titel", "fr": "Titre"},
|
||||
"message": {"en": "Message", "de": "Nachricht", "fr": "Message"},
|
||||
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
|
||||
"referenceType": {"en": "Reference Type", "de": "Referenz-Typ", "fr": "Type de référence"},
|
||||
"referenceId": {"en": "Reference ID", "de": "Referenz-ID", "fr": "ID de référence"},
|
||||
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
|
||||
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
|
||||
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
|
||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
|
||||
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
|
||||
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"NotificationType",
|
||||
{"en": "Notification Type", "de": "Benachrichtigungs-Typ", "fr": "Type de notification"},
|
||||
{
|
||||
"invitation": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
|
||||
"system": {"en": "System", "de": "System", "fr": "Système"},
|
||||
"workflow": {"en": "Workflow", "de": "Workflow", "fr": "Workflow"},
|
||||
"mention": {"en": "Mention", "de": "Erwähnung", "fr": "Mention"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"NotificationStatus",
|
||||
{"en": "Notification Status", "de": "Benachrichtigungs-Status", "fr": "Statut de notification"},
|
||||
{
|
||||
"unread": {"en": "Unread", "de": "Ungelesen", "fr": "Non lu"},
|
||||
"read": {"en": "Read", "de": "Gelesen", "fr": "Lu"},
|
||||
"actioned": {"en": "Actioned", "de": "Bearbeitet", "fr": "Traité"},
|
||||
"dismissed": {"en": "Dismissed", "de": "Verworfen", "fr": "Rejeté"},
|
||||
},
|
||||
)
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""RBAC models: AccessRule, AccessRuleContext, Role."""
|
||||
"""
|
||||
RBAC models: AccessRule, AccessRuleContext, Role.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Role hat einen unveränderlichen Kontext (mandateId, featureInstanceId, featureCode)
|
||||
- AccessRule referenziert Role via roleId (FK), nicht via roleLabel
|
||||
- Kontext-Felder sind IMMUTABLE nach Erstellung
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
|
|
@ -19,11 +26,21 @@ class AccessRuleContext(str, Enum):
|
|||
|
||||
|
||||
class Role(BaseModel):
|
||||
"""Data model for RBAC roles"""
|
||||
"""
|
||||
Data model for RBAC roles.
|
||||
|
||||
Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext.
|
||||
Der Kontext ist IMMUTABLE nach Erstellung.
|
||||
|
||||
Kontext-Typen:
|
||||
- mandateId=None, featureInstanceId=None → GLOBAL (Template-Rolle)
|
||||
- mandateId=X, featureInstanceId=None → MANDATE-Rolle
|
||||
- mandateId=X, featureInstanceId=Y → INSTANCE-Rolle
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the role",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
roleLabel: str = Field(
|
||||
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
|
||||
|
|
@ -33,106 +50,163 @@ class Role(BaseModel):
|
|||
description="Role description in multiple languages",
|
||||
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
|
||||
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
|
||||
)
|
||||
featureCode: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature code (z.B. 'trustee') - für Template-Rollen",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
isSystemRole: bool = Field(
|
||||
False,
|
||||
default=False,
|
||||
description="Whether this is a system role that cannot be deleted",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"Role",
|
||||
{"en": "Role", "fr": "Rôle"},
|
||||
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
|
||||
"description": {"en": "Description", "fr": "Description"},
|
||||
"isSystemRole": {"en": "System Role", "fr": "Rôle système"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
|
||||
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
|
||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
||||
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
|
||||
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AccessRule(BaseModel):
|
||||
"""Data model for access control rules"""
|
||||
"""
|
||||
Data model for access control rules.
|
||||
|
||||
WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!)
|
||||
Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the access rule",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
roleLabel: str = Field(
|
||||
description="Role label this rule applies to",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
|
||||
roleId: str = Field(
|
||||
description="FK → Role.id (CASCADE DELETE!)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||
)
|
||||
context: AccessRuleContext = Field(
|
||||
description="Context type: DATA (database), UI (interface), RESOURCE (system resources)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
|
||||
{"value": "DATA", "label": {"en": "Data", "fr": "Données"}},
|
||||
{"value": "UI", "label": {"en": "UI", "fr": "Interface"}},
|
||||
{"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}}
|
||||
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
|
||||
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
|
||||
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
|
||||
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
|
||||
]}
|
||||
)
|
||||
item: Optional[str] = Field(
|
||||
None,
|
||||
default=None,
|
||||
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
view: bool = Field(
|
||||
False,
|
||||
default=False,
|
||||
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
read: Optional[AccessLevel] = Field(
|
||||
None,
|
||||
default=None,
|
||||
description="Read permission level (only for DATA context)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
||||
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||
]}
|
||||
)
|
||||
create: Optional[AccessLevel] = Field(
|
||||
None,
|
||||
default=None,
|
||||
description="Create permission level (only for DATA context)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
||||
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||
]}
|
||||
)
|
||||
update: Optional[AccessLevel] = Field(
|
||||
None,
|
||||
default=None,
|
||||
description="Update permission level (only for DATA context)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
||||
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||
]}
|
||||
)
|
||||
delete: Optional[AccessLevel] = Field(
|
||||
None,
|
||||
default=None,
|
||||
description="Delete permission level (only for DATA context)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
||||
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||
]}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"AccessRule",
|
||||
{"en": "Access Rule", "fr": "Règle d'accès"},
|
||||
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
|
||||
"context": {"en": "Context", "fr": "Contexte"},
|
||||
"item": {"en": "Item", "fr": "Élément"},
|
||||
"view": {"en": "View", "fr": "Vue"},
|
||||
"read": {"en": "Read", "fr": "Lecture"},
|
||||
"create": {"en": "Create", "fr": "Créer"},
|
||||
"update": {"en": "Update", "fr": "Mettre à jour"},
|
||||
"delete": {"en": "Delete", "fr": "Supprimer"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
|
||||
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
|
||||
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
|
||||
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
|
||||
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
|
||||
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
|
||||
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
|
||||
IMMUTABLE_FIELDS = {
|
||||
"Role": ["mandateId", "featureInstanceId", "featureCode"],
|
||||
"AccessRule": ["context", "roleId"]
|
||||
}
|
||||
|
||||
|
||||
def validateUpdateNotImmutable(model: str, updateData: dict):
|
||||
"""
|
||||
Blockiert Updates auf immutable Felder.
|
||||
Wirft ValueError wenn versucht wird, Kontext-Felder zu ändern.
|
||||
|
||||
Args:
|
||||
model: Model name (z.B. "Role", "AccessRule")
|
||||
updateData: Dictionary mit Update-Daten
|
||||
|
||||
Raises:
|
||||
ValueError: Wenn immutable Felder im Update enthalten sind
|
||||
"""
|
||||
forbidden = IMMUTABLE_FIELDS.get(model, [])
|
||||
violations = [f for f in forbidden if f in updateData]
|
||||
|
||||
if violations:
|
||||
raise ValueError(
|
||||
f"Cannot update immutable fields on {model}: {violations}. "
|
||||
f"Delete and recreate instead."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Security models: Token and AuthEvent."""
|
||||
"""
|
||||
Security models: Token and AuthEvent.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Token ist NICHT an einen Mandanten gebunden
|
||||
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
|
||||
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
|
@ -17,6 +24,14 @@ class TokenStatus(str, Enum):
|
|||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""
|
||||
Authentication Token model.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Token ist User-gebunden, NICHT Mandant-gebunden
|
||||
- Ermöglicht parallele Arbeit in mehreren Mandanten
|
||||
- Mandant-Kontext wird per Request-Header bestimmt
|
||||
"""
|
||||
id: Optional[str] = None
|
||||
userId: str
|
||||
authority: AuthAuthority
|
||||
|
|
@ -45,37 +60,36 @@ class Token(BaseModel):
|
|||
sessionId: Optional[str] = Field(
|
||||
None, description="Logical session grouping for logout revocation"
|
||||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
None, description="Mandate ID for tenant scoping of the token"
|
||||
)
|
||||
# ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
|
||||
# Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"Token",
|
||||
{"en": "Token", "fr": "Jeton"},
|
||||
{"en": "Token", "de": "Token", "fr": "Jeton"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"authority": {"en": "Authority", "fr": "Autorité"},
|
||||
"connectionId": {"en": "Connection ID", "fr": "ID de connexion"},
|
||||
"tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"},
|
||||
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
|
||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
||||
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"},
|
||||
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
||||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"revokedAt": {"en": "Revoked At", "fr": "Révoqué le"},
|
||||
"revokedBy": {"en": "Revoked By", "fr": "Révoqué par"},
|
||||
"reason": {"en": "Reason", "fr": "Raison"},
|
||||
"sessionId": {"en": "Session ID", "fr": "ID de session"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
|
||||
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
|
||||
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
|
||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
|
||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
|
||||
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
|
||||
"reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
|
||||
"sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AuthEvent(BaseModel):
|
||||
"""Authentication event for audit logging."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
|
|
@ -88,15 +102,15 @@ class AuthEvent(BaseModel):
|
|||
|
||||
registerModelLabels(
|
||||
"AuthEvent",
|
||||
{"en": "Authentication Event", "fr": "Événement d'authentification"},
|
||||
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"eventType": {"en": "Event Type", "fr": "Type d'événement"},
|
||||
"timestamp": {"en": "Timestamp", "fr": "Horodatage"},
|
||||
"ipAddress": {"en": "IP Address", "fr": "Adresse IP"},
|
||||
"userAgent": {"en": "User Agent", "fr": "Agent utilisateur"},
|
||||
"success": {"en": "Success", "fr": "Succès"},
|
||||
"details": {"en": "Details", "fr": "Détails"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
|
||||
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
|
||||
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
|
||||
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
|
||||
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
|
||||
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""UAM models: User, Mandate, UserConnection."""
|
||||
"""
|
||||
UAM models: User, Mandate, UserConnection.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- User gehört NICHT direkt zu einem Mandanten
|
||||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||||
- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
|
|
@ -51,55 +58,53 @@ class UserPermissions(BaseModel):
|
|||
description="Delete permission level"
|
||||
)
|
||||
|
||||
|
||||
class Mandate(BaseModel):
|
||||
"""
|
||||
Mandate (Mandant/Tenant) model.
|
||||
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the mandate",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
name: str = Field(
|
||||
description="Name of the mandate",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="Default language of the mandate",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": [
|
||||
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
|
||||
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
|
||||
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
|
||||
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
|
||||
]
|
||||
}
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Description of the mandate",
|
||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Indicates whether the mandate is enabled",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"Mandate",
|
||||
{"en": "Mandate", "fr": "Mandat"},
|
||||
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"name": {"en": "Name", "fr": "Nom"},
|
||||
"language": {"en": "Language", "fr": "Langue"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
|
||||
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserConnection(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"})
|
||||
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
|
||||
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
|
||||
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "connection.status"})
|
||||
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"})
|
||||
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||
|
|
@ -109,70 +114,146 @@ class UserConnection(BaseModel):
|
|||
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
||||
]})
|
||||
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"UserConnection",
|
||||
{"en": "User Connection", "fr": "Connexion utilisateur"},
|
||||
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"authority": {"en": "Authority", "fr": "Autorité"},
|
||||
"externalId": {"en": "External ID", "fr": "ID externe"},
|
||||
"externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"},
|
||||
"externalEmail": {"en": "External Email", "fr": "Email externe"},
|
||||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"connectedAt": {"en": "Connected At", "fr": "Connecté le"},
|
||||
"lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"},
|
||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
||||
"tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"},
|
||||
"tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
|
||||
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
|
||||
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
|
||||
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
|
||||
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
|
||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
||||
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
username: str = Field(description="Username for login", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
|
||||
email: Optional[EmailStr] = Field(None, description="Email address of the user", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True})
|
||||
fullName: Optional[str] = Field(None, description="Full name of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||
language: str = Field(default="en", description="Preferred language of the user", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
|
||||
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
|
||||
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
|
||||
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
|
||||
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
|
||||
]})
|
||||
enabled: bool = Field(default=True, description="Indicates whether the user is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
||||
roleLabels: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of role labels assigned to this user. All roles are opening roles (union) - if one role enables something, it is enabled.",
|
||||
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
|
||||
"""
|
||||
User model.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- User gehört NICHT direkt zu einem Mandanten
|
||||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||||
- Rollen werden über UserMandateRole gesteuert
|
||||
- isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique ID of the user",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
|
||||
)
|
||||
authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL, description="Primary authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"})
|
||||
mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
username: str = Field(
|
||||
description="Username for login (immutable after creation)",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||
)
|
||||
email: Optional[EmailStr] = Field(
|
||||
default=None,
|
||||
description="Email address of the user",
|
||||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True}
|
||||
)
|
||||
fullName: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Full name of the user",
|
||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
language: str = Field(
|
||||
default="de",
|
||||
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
|
||||
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
|
||||
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
|
||||
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
|
||||
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
|
||||
]}
|
||||
)
|
||||
|
||||
@field_validator('language', mode='before')
|
||||
@classmethod
|
||||
def _normalizeLanguage(cls, v):
|
||||
"""Normalize language to valid ISO 639-1 code."""
|
||||
if v is None:
|
||||
return "de"
|
||||
# Map common variations to standard codes
|
||||
langMap = {
|
||||
'english': 'en', 'englisch': 'en',
|
||||
'german': 'de', 'deutsch': 'de',
|
||||
'french': 'fr', 'französisch': 'fr', 'francais': 'fr',
|
||||
'italian': 'it', 'italienisch': 'it', 'italiano': 'it',
|
||||
}
|
||||
normalized = str(v).lower().strip()
|
||||
if normalized in langMap:
|
||||
return langMap[normalized]
|
||||
# If already a valid code, return as-is
|
||||
if normalized in ['de', 'en', 'fr', 'it']:
|
||||
return normalized
|
||||
# Default fallback
|
||||
return "de"
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Indicates whether the user is enabled",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
isSysAdmin: bool = Field(
|
||||
default=False,
|
||||
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
@field_validator('isSysAdmin', mode='before')
|
||||
@classmethod
|
||||
def _coerceIsSysAdmin(cls, v):
|
||||
"""Konvertiert None zu False (für bestehende DB-Einträge ohne isSysAdmin Feld)."""
|
||||
if v is None:
|
||||
return False
|
||||
return v
|
||||
|
||||
authenticationAuthority: AuthAuthority = Field(
|
||||
default=AuthAuthority.LOCAL,
|
||||
description="Primary authentication authority",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"User",
|
||||
{"en": "User", "fr": "Utilisateur"},
|
||||
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"username": {"en": "Username", "fr": "Nom d'utilisateur"},
|
||||
"email": {"en": "Email", "fr": "Email"},
|
||||
"fullName": {"en": "Full Name", "fr": "Nom complet"},
|
||||
"language": {"en": "Language", "fr": "Langue"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||
"roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"},
|
||||
"authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
||||
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
|
||||
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
|
||||
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
|
||||
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
|
||||
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
"""User model with password hash for database storage."""
|
||||
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
|
||||
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
|
||||
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"UserInDB",
|
||||
{"en": "User Access", "fr": "Accès de l'utilisateur"},
|
||||
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
|
||||
{
|
||||
"hashedPassword": {"en": "Password hash", "fr": "Hachage de mot de passe"},
|
||||
"resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"},
|
||||
"resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"},
|
||||
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
|
||||
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
|
||||
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class VoiceSettings(BaseModel):
|
|||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
||||
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
||||
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
||||
|
|
@ -28,6 +29,7 @@ registerModelLabels(
|
|||
"id": {"en": "ID", "fr": "ID"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
|
||||
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
|
||||
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
|
||||
|
|
|
|||
|
|
@ -12,12 +12,6 @@ from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrok
|
|||
# Import DocumentReferenceList at runtime (needed for ActionDefinition)
|
||||
from modules.datamodels.datamodelDocref import DocumentReferenceList
|
||||
|
||||
# Forward references for circular imports (use string annotations)
|
||||
if TYPE_CHECKING:
|
||||
from modules.datamodels.datamodelChat import ChatDocument, ActionResult
|
||||
from modules.datamodels.datamodelExtraction import ExtractionOptions
|
||||
|
||||
|
||||
class ActionDefinition(BaseModel):
|
||||
"""Action definition with selection and parameters from planning phase"""
|
||||
|
||||
|
|
|
|||
45
modules/features/automation/datamodelFeatureAutomation.py
Normal file
45
modules/features/automation/datamodelFeatureAutomation.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Automation models: AutomationDefinition."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
import uuid
|
||||
|
||||
|
||||
class AutomationDefinition(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
featureInstanceId: str = Field(description="ID of the feature instance this automation belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
|
||||
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
|
||||
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
|
||||
{"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}},
|
||||
{"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}}
|
||||
]})
|
||||
template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True})
|
||||
placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "textarea"})
|
||||
active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False})
|
||||
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
"AutomationDefinition",
|
||||
{"en": "Automation Definition", "fr": "Définition d'automatisation"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"label": {"en": "Label", "fr": "Libellé"},
|
||||
"schedule": {"en": "Schedule", "fr": "Planification"},
|
||||
"template": {"en": "Template", "fr": "Modèle"},
|
||||
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
|
||||
"active": {"en": "Active", "fr": "Actif"},
|
||||
"eventId": {"en": "Event ID", "fr": "ID de l'événement"},
|
||||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"},
|
||||
},
|
||||
)
|
||||
169
modules/features/automation/mainAutomation.py
Normal file
169
modules/features/automation/mainAutomation.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Automation Feature Container - Main Module.
|
||||
Handles feature initialization and RBAC catalog registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature metadata
|
||||
FEATURE_CODE = "automation"
|
||||
FEATURE_LABEL = {"en": "Automation", "de": "Automatisierung", "fr": "Automatisation"}
|
||||
FEATURE_ICON = "mdi-cog-clockwise"
|
||||
|
||||
# UI Objects for RBAC catalog
|
||||
UI_OBJECTS = [
|
||||
{
|
||||
"objectKey": "ui.feature.automation.definitions",
|
||||
"label": {"en": "Automation Definitions", "de": "Automatisierungs-Definitionen", "fr": "Définitions d'automatisation"},
|
||||
"meta": {"area": "definitions"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.automation.templates",
|
||||
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
|
||||
"meta": {"area": "templates"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.automation.logs",
|
||||
"label": {"en": "Execution Logs", "de": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
|
||||
"meta": {"area": "logs"}
|
||||
},
|
||||
]
|
||||
|
||||
# Resource Objects for RBAC catalog
|
||||
RESOURCE_OBJECTS = [
|
||||
{
|
||||
"objectKey": "resource.feature.automation.create",
|
||||
"label": {"en": "Create Automation", "de": "Automatisierung erstellen", "fr": "Créer automatisation"},
|
||||
"meta": {"endpoint": "/api/automations", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.automation.update",
|
||||
"label": {"en": "Update Automation", "de": "Automatisierung aktualisieren", "fr": "Modifier automatisation"},
|
||||
"meta": {"endpoint": "/api/automations/{automationId}", "method": "PUT"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.automation.delete",
|
||||
"label": {"en": "Delete Automation", "de": "Automatisierung löschen", "fr": "Supprimer automatisation"},
|
||||
"meta": {"endpoint": "/api/automations/{automationId}", "method": "DELETE"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.automation.execute",
|
||||
"label": {"en": "Execute Automation", "de": "Automatisierung ausführen", "fr": "Exécuter automatisation"},
|
||||
"meta": {"endpoint": "/api/automations/{automationId}/execute", "method": "POST"}
|
||||
},
|
||||
]
|
||||
|
||||
# Template roles for this feature
|
||||
TEMPLATE_ROLES = [
|
||||
{
|
||||
"roleLabel": "automation-admin",
|
||||
"description": {
|
||||
"en": "Automation Administrator - Full access to automation configuration and execution",
|
||||
"de": "Automatisierungs-Administrator - Vollzugriff auf Automatisierungs-Konfiguration und Ausführung",
|
||||
"fr": "Administrateur automatisation - Accès complet à la configuration et exécution"
|
||||
},
|
||||
"accessRules": [
|
||||
# Full UI access
|
||||
{"context": "UI", "item": None, "view": True},
|
||||
# Full DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "automation-editor",
|
||||
"description": {
|
||||
"en": "Automation Editor - Create and modify automations",
|
||||
"de": "Automatisierungs-Editor - Automatisierungen erstellen und bearbeiten",
|
||||
"fr": "Éditeur automatisation - Créer et modifier les automatisations"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to definitions and templates - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.automation.templates", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
|
||||
# Group-level DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "automation-viewer",
|
||||
"description": {
|
||||
"en": "Automation Viewer - View automations and execution results",
|
||||
"de": "Automatisierungs-Betrachter - Automatisierungen und Ausführungsergebnisse einsehen",
|
||||
"fr": "Visualiseur automatisation - Consulter les automatisations et résultats"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to view only - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
|
||||
# Read-only DATA access (my level)
|
||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def getFeatureDefinition() -> Dict[str, Any]:
|
||||
"""Return the feature definition for registration."""
|
||||
return {
|
||||
"code": FEATURE_CODE,
|
||||
"label": FEATURE_LABEL,
|
||||
"icon": FEATURE_ICON
|
||||
}
|
||||
|
||||
|
||||
def getUiObjects() -> List[Dict[str, Any]]:
|
||||
"""Return UI objects for RBAC catalog registration."""
|
||||
return UI_OBJECTS
|
||||
|
||||
|
||||
def getResourceObjects() -> List[Dict[str, Any]]:
|
||||
"""Return resource objects for RBAC catalog registration."""
|
||||
return RESOURCE_OBJECTS
|
||||
|
||||
|
||||
def getTemplateRoles() -> List[Dict[str, Any]]:
|
||||
"""Return template roles for this feature."""
|
||||
return TEMPLATE_ROLES
|
||||
|
||||
|
||||
def registerFeature(catalogService) -> bool:
|
||||
"""
|
||||
Register this feature's RBAC objects in the catalog.
|
||||
|
||||
Args:
|
||||
catalogService: The RBAC catalog service instance
|
||||
|
||||
Returns:
|
||||
True if registration was successful
|
||||
"""
|
||||
try:
|
||||
# Register UI objects
|
||||
for uiObj in UI_OBJECTS:
|
||||
catalogService.registerUiObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=uiObj["objectKey"],
|
||||
label=uiObj["label"],
|
||||
meta=uiObj.get("meta")
|
||||
)
|
||||
|
||||
# Register Resource objects
|
||||
for resObj in RESOURCE_OBJECTS:
|
||||
catalogService.registerResourceObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=resObj["objectKey"],
|
||||
label=resObj["label"],
|
||||
meta=resObj.get("meta")
|
||||
)
|
||||
|
||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
||||
return False
|
||||
|
|
@ -13,13 +13,14 @@ import logging
|
|||
import json
|
||||
|
||||
# Import interfaces and models
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow
|
||||
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
|
||||
from modules.datamodels.datamodelChat import ChatWorkflow
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||
from modules.features.workflow import executeAutomation
|
||||
from modules.features.workflow.subAutomationTemplates import getAutomationTemplates
|
||||
from modules.workflows.automation import executeAutomation
|
||||
from .subAutomationTemplates import getAutomationTemplates
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -45,7 +46,7 @@ router = APIRouter(
|
|||
async def get_automations(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[AutomationDefinition]:
|
||||
"""
|
||||
Get automation definitions with optional pagination, sorting, and filtering.
|
||||
|
|
@ -68,7 +69,7 @@ async def get_automations(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult
|
||||
|
|
@ -110,14 +111,14 @@ async def get_automations(
|
|||
async def create_automation(
|
||||
request: Request,
|
||||
automation: AutomationDefinition,
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> AutomationDefinition:
|
||||
"""Create a new automation definition"""
|
||||
try:
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
automationData = automation.model_dump()
|
||||
created = chatInterface.createAutomationDefinition(automationData)
|
||||
return AutomationDefinition(**created)
|
||||
return created
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -131,7 +132,7 @@ async def create_automation(
|
|||
@limiter.limit("30/minute")
|
||||
async def get_automation_templates(
|
||||
request: Request,
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get automation templates from backend module.
|
||||
|
|
@ -159,11 +160,11 @@ async def get_automation_attributes(
|
|||
async def get_automation(
|
||||
request: Request,
|
||||
automationId: str = Path(..., description="Automation ID"),
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> AutomationDefinition:
|
||||
"""Get a single automation definition by ID"""
|
||||
try:
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
automation = chatInterface.getAutomationDefinition(automationId)
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
|
|
@ -171,7 +172,7 @@ async def get_automation(
|
|||
detail=f"Automation {automationId} not found"
|
||||
)
|
||||
|
||||
return AutomationDefinition(**automation)
|
||||
return automation
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -187,14 +188,14 @@ async def update_automation(
|
|||
request: Request,
|
||||
automationId: str = Path(..., description="Automation ID"),
|
||||
automation: AutomationDefinition = Body(...),
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> AutomationDefinition:
|
||||
"""Update an automation definition"""
|
||||
try:
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
automationData = automation.model_dump()
|
||||
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
||||
return AutomationDefinition(**updated)
|
||||
return updated
|
||||
except HTTPException:
|
||||
raise
|
||||
except PermissionError as e:
|
||||
|
|
@ -209,16 +210,57 @@ async def update_automation(
|
|||
detail=f"Error updating automation: {str(e)}"
|
||||
)
|
||||
|
||||
@router.patch("/{automationId}/status")
|
||||
@limiter.limit("30/minute")
|
||||
async def update_automation_status(
|
||||
request: Request,
|
||||
automationId: str = Path(..., description="Automation ID"),
|
||||
active: bool = Body(..., embed=True),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> AutomationDefinition:
|
||||
"""Update only the active status of an automation definition"""
|
||||
try:
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
|
||||
# Get existing automation
|
||||
automation = chatInterface.getAutomationDefinition(automationId)
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Automation {automationId} not found"
|
||||
)
|
||||
|
||||
# Update only the active field
|
||||
automationData = automation if isinstance(automation, dict) else automation.model_dump()
|
||||
automationData['active'] = active
|
||||
|
||||
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
||||
return updated
|
||||
except HTTPException:
|
||||
raise
|
||||
except PermissionError as e:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating automation status: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error updating automation status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{automationId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def delete_automation(
|
||||
request: Request,
|
||||
automationId: str = Path(..., description="Automation ID"),
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Response:
|
||||
"""Delete an automation definition"""
|
||||
try:
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
|
||||
success = chatInterface.deleteAutomationDefinition(automationId)
|
||||
if success:
|
||||
return Response(status_code=204)
|
||||
|
|
@ -243,15 +285,15 @@ async def delete_automation(
|
|||
|
||||
@router.post("/{automationId}/execute", response_model=ChatWorkflow)
|
||||
@limiter.limit("5/minute")
|
||||
async def execute_automation(
|
||||
async def execute_automation_route(
|
||||
request: Request,
|
||||
automationId: str = Path(..., description="Automation ID"),
|
||||
currentUser = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Execute an automation immediately (test mode)"""
|
||||
try:
|
||||
from modules.services import getInterface as getServices
|
||||
services = getServices(currentUser, None)
|
||||
services = getServices(context.user, context.mandateId)
|
||||
workflow = await executeAutomation(automationId, services)
|
||||
return workflow
|
||||
except HTTPException:
|
||||
417
modules/features/automation/subAutomationTemplates.py
Normal file
417
modules/features/automation/subAutomationTemplates.py
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Automation templates for workflow definitions.
|
||||
|
||||
Contains predefined workflow templates that can be used to create automation definitions.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# Automation templates structure
|
||||
AUTOMATION_TEMPLATES: Dict[str, Any] = {
|
||||
"sets": [
|
||||
{
|
||||
"template": {
|
||||
"overview": "SharePoint Themen Zusammenfassung",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task01",
|
||||
"title": "SharePoint Themen Zusammenfassung",
|
||||
"description": "Erstellt eine Zusammenfassung aller SharePoint Sites und deren Inhalte",
|
||||
"objective": "Erstelle eine Zusammenfassung aller SharePoint Themen (Sites) und deren Inhalte als Word-Dokument",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "findDocumentPath",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"searchQuery": "*",
|
||||
"maxResults": 100
|
||||
},
|
||||
"execResultLabel": "sharepoint_sites_found"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "listDocuments",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"pathQuery": "{{KEY:sharepointBasePath}}",
|
||||
"includeSubfolders": True
|
||||
},
|
||||
"execResultLabel": "sharepoint_structure"
|
||||
},
|
||||
{
|
||||
"execMethod": "ai",
|
||||
"execAction": "process",
|
||||
"execParameters": {
|
||||
"aiPrompt": "{{KEY:summaryPrompt}}",
|
||||
"documentList": ["sharepoint_sites_found", "sharepoint_structure"],
|
||||
"resultType": "docx"
|
||||
},
|
||||
"execResultLabel": "sharepoint_summary"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "uploadDocument",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"documentList": ["sharepoint_summary"],
|
||||
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
|
||||
},
|
||||
"execResultLabel": "sharepoint_upload_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"connectionName": "connection:msft:p.motsch@valueon.ch",
|
||||
"sharepointBasePath": "/sites/company-share",
|
||||
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
|
||||
"summaryPrompt": "Erstelle eine umfassende Zusammenfassung aller SharePoint Sites und deren Inhalte. Strukturiere das Dokument nach Sites und fasse für jede Site die wichtigsten Themen, Ordnerstrukturen und Dokumente zusammen. Erstelle ein professionelles Word-Dokument mit Überschriften, Abschnitten und einer klaren Gliederung. Berücksichtige alle gefundenen Sites, deren Ordnerstrukturen und dokumentiere die wichtigsten Inhalte pro Site."
|
||||
}
|
||||
},
|
||||
{
|
||||
"template": {
|
||||
"overview": "Immobilienrecherche Zürich",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task02",
|
||||
"title": "Immobilienrecherche Zürich",
|
||||
"description": "Webrecherche nach Immobilien im Kanton Zürich und Speicherung in Excel",
|
||||
"objective": "Immobilienrecherche im Kanton Zürich zum Verkauf (5-20 Mio. CHF) und speichere Ergebnisse in Excel-Liste auf SharePoint",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "ai",
|
||||
"execAction": "webResearch",
|
||||
"execParameters": {
|
||||
"prompt": "{{KEY:immobilienResearchPrompt}}",
|
||||
"urlList": ["{{KEY:immobilienResearchUrl}}"]
|
||||
},
|
||||
"execResultLabel": "immobilien_research_results"
|
||||
},
|
||||
{
|
||||
"execMethod": "ai",
|
||||
"execAction": "process",
|
||||
"execParameters": {
|
||||
"aiPrompt": "{{KEY:excelFormatPrompt}}",
|
||||
"documentList": ["immobilien_research_results"],
|
||||
"resultType": "xlsx"
|
||||
},
|
||||
"execResultLabel": "immobilien_excel_list"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "uploadDocument",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"documentList": ["immobilien_excel_list"],
|
||||
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
|
||||
},
|
||||
"execResultLabel": "immobilien_upload_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"connectionName": "connection:msft:p.motsch@valueon.ch",
|
||||
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
|
||||
"immobilienResearchUrl": ["https://www.homegate.ch", "https://www.immoscout24.ch", "https://www.immowelt.ch"],
|
||||
"immobilienResearchPrompt": "Suche nach Immobilien zum Verkauf im Kanton Zürich, Schweiz, im Preisbereich von 5-20 Millionen CHF. Sammle Informationen zu: Ort, Preis, Beschreibung, URL zu Bildern, Verkäufer/Kontaktinformationen.",
|
||||
"excelFormatPrompt": "Erstelle eine Excel-Datei mit den recherchierten Immobilien. Jede Immobilie soll eine Zeile sein mit den folgenden Spalten: Ort, Preis (in CHF), Beschreibung, URL zu Bild, Verkäufer. Verwende die Daten aus der Webrecherche."
|
||||
}
|
||||
},
|
||||
{
|
||||
"template": {
|
||||
"overview": "Spesenbelege Zusammenfassung",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task03",
|
||||
"title": "Spesenbelege CSV Zusammenfassung",
|
||||
"description": "Liest PDF-Spesenbelege aus SharePoint-Ordner und erstellt CSV-Zusammenfassung",
|
||||
"objective": "Extrahiere alle PDF-Spesenbelege aus einem SharePoint-Ordner und erstelle eine CSV-Datei mit allen Spesendaten im selben Ordner",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "findDocumentPath",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"searchQuery": "{{KEY:sharepointFolderNameSource}}:files:.pdf",
|
||||
"maxResults": 100
|
||||
},
|
||||
"execResultLabel": "sharepoint_pdf_files"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "readDocuments",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"pathObject": "sharepoint_pdf_files"
|
||||
},
|
||||
"execResultLabel": "spesenbelege_documents"
|
||||
},
|
||||
{
|
||||
"execMethod": "ai",
|
||||
"execAction": "process",
|
||||
"execParameters": {
|
||||
"aiPrompt": "{{KEY:expenseExtractionPrompt}}",
|
||||
"documentList": ["spesenbelege_documents"],
|
||||
"resultType": "csv"
|
||||
},
|
||||
"execResultLabel": "spesenbelege_csv"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "uploadDocument",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"documentList": ["spesenbelege_csv"],
|
||||
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
|
||||
},
|
||||
"execResultLabel": "spesenbelege_upload_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"connectionName": "connection:msft:p.motsch@valueon.ch",
|
||||
"sharepointFolderNameSource": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/expenses",
|
||||
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
|
||||
"expenseExtractionPrompt": "Verarbeite alle bereitgestellten Dokumente, aber extrahiere nur Daten aus PDF-Spesenbelegen (ignoriere andere Dateitypen). Für jeden gefundenen PDF-Spesenbeleg extrahiere als separaten Datensatz: Datum, Betrag, MWST %, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler/Verkäufer, Steuerbetrag. Erstelle eine CSV-Datei mit einer Zeile pro Spesenbeleg. Verwende die folgenden Spaltenüberschriften: Datum, Betrag, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler, Steuerbetrag. Stelle sicher, dass alle Beträge numerisch sind und Datumswerte im Format YYYY-MM-DD vorliegen. Wenn ein Dokument kein Spesenbeleg ist, ignoriere es."
|
||||
}
|
||||
},
|
||||
{
|
||||
"template": {
|
||||
"overview": "Preprocessing Server Data Update",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task04",
|
||||
"title": "Trigger Preprocessing Server",
|
||||
"description": "Triggers the preprocessing server at customer tenant to update database with configuration",
|
||||
"objective": "Call preprocessing server endpoint to update database with provided configuration JSON",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "context",
|
||||
"execAction": "triggerPreprocessingServer",
|
||||
"execParameters": {
|
||||
"endpoint": "{{KEY:endpoint}}",
|
||||
"configJson": "{{KEY:configJson}}",
|
||||
"authSecretConfigKey": "{{KEY:authSecretConfigKey}}"
|
||||
},
|
||||
"execResultLabel": "preprocessing_server_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"endpoint": "https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataprocessor/update-db-with-config",
|
||||
"authSecretConfigKey": "PREPROCESS_ALTHAUS_CHAT_SECRET",
|
||||
"configJson": "{\"tables\":[{\"name\":\"Artikel\",\"powerbi_table_name\":\"Artikel\",\"steps\":[{\"keep\":{\"columns\":[\"I_ID\",\"Artikelbeschrieb\",\"Artikelbezeichnung\",\"Artikelgruppe\",\"Artikelkategorie\",\"Artikelkürzel\",\"Artikelnummer\",\"Einheit\",\"Gesperrt\",\"Keywords\",\"Lieferant\",\"Warengruppe\"]}},{\"fillna\":{\"column\":\"Lieferant\",\"value\":\"Unbekannt\"}}]},{\"name\":\"Einkaufspreis\",\"powerbi_table_name\":\"Einkaufspreis\",\"steps\":[{\"to_numeric\":{\"column\":\"EP_CHF\",\"errors\":\"coerce\"}},{\"dropna\":{\"subset\":[\"EP_CHF\"]}}]}]}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"template": {
|
||||
"overview": "JIRA to SharePoint Ticket Synchronization",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task01",
|
||||
"title": "Sync JIRA Tickets to SharePoint",
|
||||
"description": "Export JIRA tickets, merge with SharePoint file, upload back, and import changes to JIRA",
|
||||
"objective": "Synchronize JIRA tickets with SharePoint file (bidirectional sync)",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "findSiteByUrl",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:sharepointConnection}}",
|
||||
"hostname": "{{KEY:sharepointHostname}}",
|
||||
"sitePath": "{{KEY:sharepointSitePath}}"
|
||||
},
|
||||
"execResultLabel": "sharepoint_site"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "connectJira",
|
||||
"execParameters": {
|
||||
"apiUsername": "{{KEY:jiraUsername}}",
|
||||
"apiTokenConfigKey": "{{KEY:jiraTokenConfigKey}}",
|
||||
"apiUrl": "{{KEY:jiraUrl}}",
|
||||
"projectCode": "{{KEY:jiraProjectCode}}",
|
||||
"issueType": "{{KEY:jiraIssueType}}",
|
||||
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
|
||||
},
|
||||
"execResultLabel": "jira_connection"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "exportTicketsAsJson",
|
||||
"execParameters": {
|
||||
"connectionId": "jira_connection",
|
||||
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
|
||||
},
|
||||
"execResultLabel": "jira_exported_tickets"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "downloadFileByPath",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:sharepointConnection}}",
|
||||
"siteId": "sharepoint_site",
|
||||
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
|
||||
},
|
||||
"execResultLabel": "existing_file_content"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "parseExcelContent",
|
||||
"execParameters": {
|
||||
"excelContent": "existing_file_content",
|
||||
"skipRows": 3,
|
||||
"hasCustomHeaders": True
|
||||
},
|
||||
"execResultLabel": "existing_parsed_data"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "mergeTicketData",
|
||||
"execParameters": {
|
||||
"jiraData": "jira_exported_tickets",
|
||||
"existingData": "existing_parsed_data",
|
||||
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}",
|
||||
"idField": "ID"
|
||||
},
|
||||
"execResultLabel": "merged_ticket_data"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "copyFile",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:sharepointConnection}}",
|
||||
"siteId": "sharepoint_site",
|
||||
"sourceFolder": "{{KEY:sharepointMainFolder}}",
|
||||
"sourceFile": "{{KEY:syncFileName}}",
|
||||
"destFolder": "{{KEY:sharepointBackupFolder}}",
|
||||
"destFile": "backup_{{TIMESTAMP}}_{{KEY:syncFileName}}"
|
||||
},
|
||||
"execResultLabel": "file_backup"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "createExcelContent",
|
||||
"execParameters": {
|
||||
"data": "merged_ticket_data",
|
||||
"headers": "existing_parsed_data",
|
||||
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
|
||||
},
|
||||
"execResultLabel": "new_file_content"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "uploadFile",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:sharepointConnection}}",
|
||||
"siteId": "sharepoint_site",
|
||||
"folderPath": "{{KEY:sharepointMainFolder}}",
|
||||
"fileName": "{{KEY:syncFileName}}",
|
||||
"content": "new_file_content"
|
||||
},
|
||||
"execResultLabel": "uploaded_file"
|
||||
},
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "downloadFileByPath",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:sharepointConnection}}",
|
||||
"siteId": "sharepoint_site",
|
||||
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
|
||||
},
|
||||
"execResultLabel": "uploaded_file_content"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "parseExcelContent",
|
||||
"execParameters": {
|
||||
"excelContent": "uploaded_file_content",
|
||||
"skipRows": 3,
|
||||
"hasCustomHeaders": True
|
||||
},
|
||||
"execResultLabel": "import_data"
|
||||
},
|
||||
{
|
||||
"execMethod": "jira",
|
||||
"execAction": "importTicketsFromJson",
|
||||
"execParameters": {
|
||||
"connectionId": "jira_connection",
|
||||
"ticketData": "import_data",
|
||||
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
|
||||
},
|
||||
"execResultLabel": "import_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"sharepointConnection": "connection:msft:patrick.motsch@delta.ch",
|
||||
"sharepointHostname": "deltasecurityag.sharepoint.com",
|
||||
"sharepointSitePath": "SteeringBPM",
|
||||
"sharepointMainFolder": "/General/50 Docs hosted by SELISE",
|
||||
"sharepointBackupFolder": "/General/50 Docs hosted by SELISE/SyncHistory",
|
||||
"syncFileName": "DELTAgroup x SELISE Ticket Exchange List.xlsx",
|
||||
"jiraUsername": "p.motsch@valueon.ch",
|
||||
"jiraTokenConfigKey": "Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET",
|
||||
"jiraUrl": "https://deltasecurity.atlassian.net",
|
||||
"jiraProjectCode": "DCS",
|
||||
"jiraIssueType": "Task",
|
||||
"taskSyncDefinition": "{\"ID\":[\"get\",[\"key\"]],\"Module Category\":[\"get\",[\"fields\",\"customfield_10058\",\"value\"]],\"Summary\":[\"get\",[\"fields\",\"summary\"]],\"Description\":[\"get\",[\"fields\",\"description\"]],\"References\":[\"get\",[\"fields\",\"customfield_10066\"]],\"Priority\":[\"get\",[\"fields\",\"priority\",\"name\"]],\"Issue Status\":[\"get\",[\"fields\",\"status\",\"name\"]],\"Assignee\":[\"get\",[\"fields\",\"assignee\",\"displayName\"]],\"Issue Created\":[\"get\",[\"fields\",\"created\"]],\"Due Date\":[\"get\",[\"fields\",\"duedate\"]],\"DELTA Comments\":[\"get\",[\"fields\",\"customfield_10167\"]],\"SELISE Ticket References\":[\"put\",[\"fields\",\"customfield_10067\"]],\"SELISE Status Values\":[\"put\",[\"fields\",\"customfield_10065\"]],\"SELISE Comments\":[\"put\",[\"fields\",\"customfield_10168\"]]}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"template": {
|
||||
"overview": "Expenses PDF to Trustee Position",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "Task01",
|
||||
"title": "Extract Expenses from SharePoint PDFs",
|
||||
"description": "Reads PDF expense documents from SharePoint folder, extracts data via AI, and saves to TrusteePosition",
|
||||
"objective": "Extract expense data from PDF documents and store in Trustee database with automatic file organization",
|
||||
"actionList": [
|
||||
{
|
||||
"execMethod": "sharepoint",
|
||||
"execAction": "getExpensesFromPdf",
|
||||
"execParameters": {
|
||||
"connectionReference": "{{KEY:connectionName}}",
|
||||
"sharepointFolder": "{{KEY:sharepointFolder}}",
|
||||
"featureInstanceId": "{{KEY:featureInstanceId}}",
|
||||
"prompt": "{{KEY:extractionPrompt}}"
|
||||
},
|
||||
"execResultLabel": "expense_extraction_result"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"connectionName": "",
|
||||
"sharepointFolder": "",
|
||||
"featureInstanceId": "",
|
||||
"extractionPrompt": "Du bist ein Spezialist für die Extraktion von Belegdaten aus PDF-Dokumenten.\n\nAUFGABE:\nExtrahiere die Daten aus dem bereitgestellten Zahlungsbeleg und erstelle EINE EINZIGE CSV-Tabelle mit allen Datensätzen.\n\nOUTPUT-STRUKTUR:\nErstelle genau EINE Tabelle mit den folgenden Spalten. Alle extrahierten Datensätze kommen in diese eine Tabelle als Zeilen.\n\nWICHTIGE REGELN:\n1. Pro MwSt-Prozentsatz einen separaten Datensatz (= Zeile) erstellen\n2. Alle Datensätze zusammen müssen den Gesamtbetrag des Dokuments ergeben\n3. Der gesamte extrahierte Text des Dokuments muss im Feld \"desc\" erfasst werden\n4. Feld \"company\" enthält den Lieferanten/Verkäufer der Buchung\n5. Tags müssen aus dieser Liste gewählt werden: customer, meeting, license, subscription, fuel, food, material\n - Mehrere zutreffende Tags mit Komma trennen\n\nCSV-SPALTEN (in dieser Reihenfolge):\nvaluta,transactionDateTime,company,desc,tags,bookingCurrency,bookingAmount,originalCurrency,originalAmount,vatPercentage,vatAmount\n\nDATENFORMAT:\n- valuta: YYYY-MM-DD (Valutadatum)\n- transactionDateTime: Unix-Timestamp in Sekunden (Transaktionszeitpunkt)\n- company: Lieferant/Verkäufer Name\n- desc: Vollständiger extrahierter Text des Dokuments\n- tags: Komma-getrennte Tags aus der erlaubten Liste\n- bookingCurrency: Währungscode (CHF, EUR, USD, GBP)\n- bookingAmount: Buchungsbetrag als Dezimalzahl\n- originalCurrency: Original-Währungscode\n- originalAmount: Original-Betrag als Dezimalzahl\n- vatPercentage: MwSt-Prozentsatz (z.B. 8.1 für 8.1%)\n- vatAmount: MwSt-Betrag als Dezimalzahl\n\nHINWEISE:\n- Wenn nur ein MwSt-Satz vorhanden ist, einen Datensatz erstellen\n- Wenn mehrere MwSt-Sätze vorhanden sind (z.B. Lebensmittel 2.6% und Non-Food 8.1%), separate Datensätze erstellen\n- Bei fehlenden Informationen: leeres Feld oder Standardwert"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def getAutomationTemplates() -> Dict[str, Any]:
|
||||
"""
|
||||
Get automation templates.
|
||||
|
||||
Returns:
|
||||
Dict containing the automation templates structure with 'sets' key.
|
||||
"""
|
||||
return AUTOMATION_TEMPLATES
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"""
|
||||
Utility functions for automation feature.
|
||||
|
||||
Moved from interfaces/interfaceDbChatObjects.py.
|
||||
Moved from interfaces/interfaceDbChat.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
1024
modules/features/chatbot/datamodelFeatureChatbot.py
Normal file
1024
modules/features/chatbot/datamodelFeatureChatbot.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Interface to LucyDOM database and AI Connectors.
|
||||
Uses the JSON connector for data access with added language support.
|
||||
Interface to Chatbot database and AI Connectors.
|
||||
Uses the PostgreSQL connector for data access with user/mandate filtering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -16,16 +16,16 @@ from modules.security.rbac import RbacClass
|
|||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||
from modules.datamodels.datamodelUam import AccessLevel
|
||||
|
||||
from modules.datamodels.datamodelChat import (
|
||||
from .datamodelFeatureChatbot import (
|
||||
ChatDocument,
|
||||
ChatStat,
|
||||
ChatLog,
|
||||
ChatMessage,
|
||||
ChatWorkflow,
|
||||
WorkflowModeEnum,
|
||||
AutomationDefinition,
|
||||
UserInputRequest
|
||||
)
|
||||
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
|
||||
import json
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
|||
import os
|
||||
from datetime import datetime, UTC
|
||||
from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir
|
||||
from modules.interfaces.interfaceDbComponentObjects import getInterface
|
||||
from modules.interfaces.interfaceDbManagement import getInterface
|
||||
|
||||
# Create base debug directory (use base debug dir, not prompts subdirectory)
|
||||
baseDebugDir = _getBaseDebugDir()
|
||||
|
|
@ -178,12 +178,20 @@ class ChatObjects:
|
|||
Uses the JSON connector for data access with added language support.
|
||||
"""
|
||||
|
||||
def __init__(self, currentUser: Optional[User] = None):
|
||||
"""Initializes the Chat Interface."""
|
||||
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Initializes the Chat Interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
|
||||
"""
|
||||
# Initialize variables
|
||||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.mandateId = currentUser.mandateId if currentUser else None
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
self.rbac = None # RBAC interface
|
||||
|
||||
# Initialize services
|
||||
|
|
@ -194,7 +202,7 @@ class ChatObjects:
|
|||
|
||||
# Set user context if provided
|
||||
if currentUser:
|
||||
self.setUserContext(currentUser)
|
||||
self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||
|
||||
# ===== Generic Utility Methods =====
|
||||
|
||||
|
|
@ -257,14 +265,27 @@ class ChatObjects:
|
|||
def _initializeServices(self):
|
||||
pass
|
||||
|
||||
def setUserContext(self, currentUser: User):
|
||||
"""Sets the user context for the interface."""
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Sets the user context for the interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
|
||||
"""
|
||||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id
|
||||
self.mandateId = currentUser.mandateId
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
|
||||
if not self.userId or not self.mandateId:
|
||||
raise ValueError("Invalid user context: id and mandateId are required")
|
||||
if not self.userId:
|
||||
raise ValueError("Invalid user context: id is required")
|
||||
|
||||
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
|
||||
# Users are NOT assigned to mandates by design - they get mandate context from the request.
|
||||
# sysAdmin users can additionally perform cross-mandate operations.
|
||||
# Without mandateId, operations will be filtered to accessible mandates via RBAC.
|
||||
|
||||
# Add language settings
|
||||
self.userLanguage = currentUser.language # Default user language
|
||||
|
|
@ -293,11 +314,11 @@ class ChatObjects:
|
|||
"""Initializes the database connection directly."""
|
||||
try:
|
||||
# Get configuration values with defaults
|
||||
dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data")
|
||||
dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat")
|
||||
dbUser = APP_CONFIG.get("DB_CHAT_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432))
|
||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||
dbDatabase = "poweron_chatbot"
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
# Create database connector directly
|
||||
self.db = DatabaseConnector(
|
||||
|
|
@ -346,7 +367,9 @@ class ChatObjects:
|
|||
permissions = self.rbac.getUserPermissions(
|
||||
self.currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
tableName
|
||||
tableName,
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId
|
||||
)
|
||||
|
||||
if operation == "create":
|
||||
|
|
@ -587,10 +610,12 @@ class ChatObjects:
|
|||
If pagination is None: List[Dict[str, Any]]
|
||||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
# Use RBAC filtering
|
||||
# Use RBAC filtering with featureInstanceId for instance-level isolation
|
||||
filteredWorkflows = getRecordsetWithRBAC(self.db,
|
||||
ChatWorkflow,
|
||||
self.currentUser
|
||||
self.currentUser,
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId
|
||||
)
|
||||
|
||||
# If no pagination requested, return all items (no sorting - frontend handles it)
|
||||
|
|
@ -622,11 +647,13 @@ class ChatObjects:
|
|||
|
||||
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
|
||||
"""Returns a workflow by ID if user has access."""
|
||||
# Use RBAC filtering
|
||||
# Use RBAC filtering with featureInstanceId for instance-level isolation
|
||||
workflows = getRecordsetWithRBAC(self.db,
|
||||
ChatWorkflow,
|
||||
self.currentUser,
|
||||
recordFilter={"id": workflowId}
|
||||
recordFilter={"id": workflowId},
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId
|
||||
)
|
||||
|
||||
if not workflows:
|
||||
|
|
@ -654,7 +681,7 @@ class ChatObjects:
|
|||
logs=logs,
|
||||
messages=messages,
|
||||
stats=stats,
|
||||
mandateId=workflow.get("mandateId", self.currentUser.mandateId)
|
||||
mandateId=workflow.get("mandateId", self.mandateId)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating workflow data: {str(e)}")
|
||||
|
|
@ -673,6 +700,12 @@ class ChatObjects:
|
|||
if "lastActivity" not in workflowData:
|
||||
workflowData["lastActivity"] = currentTime
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in workflowData or not workflowData["mandateId"]:
|
||||
workflowData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]:
|
||||
workflowData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Use generic field separation based on ChatWorkflow model
|
||||
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
|
||||
|
||||
|
|
@ -695,7 +728,7 @@ class ChatObjects:
|
|||
logs=[],
|
||||
messages=[],
|
||||
stats=[],
|
||||
mandateId=created.get("mandateId", self.currentUser.mandateId),
|
||||
mandateId=created.get("mandateId", self.mandateId),
|
||||
workflowMode=created["workflowMode"],
|
||||
maxSteps=created.get("maxSteps", 1)
|
||||
)
|
||||
|
|
@ -993,6 +1026,12 @@ class ChatObjects:
|
|||
if "actionNumber" not in messageData:
|
||||
messageData["actionNumber"] = workflow.currentAction
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in messageData or not messageData["mandateId"]:
|
||||
messageData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]:
|
||||
messageData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Use generic field separation based on ChatMessage model
|
||||
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
|
||||
|
||||
|
|
@ -1088,7 +1127,7 @@ class ChatObjects:
|
|||
logger.error(f"Error creating workflow message: {str(e)}")
|
||||
return None
|
||||
|
||||
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]:
|
||||
"""Updates a workflow message if user has access to the workflow."""
|
||||
try:
|
||||
|
||||
|
|
@ -1174,8 +1213,10 @@ class ChatObjects:
|
|||
logger.error(f"Error updating message documents: {str(e)}")
|
||||
if not updatedMessage:
|
||||
logger.warning(f"Failed to update message {messageId}")
|
||||
|
||||
return updatedMessage
|
||||
return None
|
||||
|
||||
# Convert to ChatMessage model
|
||||
return ChatMessage(**updatedMessage)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
|
||||
raise ValueError(f"Error updating message {messageId}: {str(e)}")
|
||||
|
|
@ -1285,6 +1326,12 @@ class ChatObjects:
|
|||
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
|
||||
"""Creates a document for a message in normalized table."""
|
||||
try:
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in documentData or not documentData["mandateId"]:
|
||||
documentData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]:
|
||||
documentData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Validate and normalize document data to dict
|
||||
document = ChatDocument(**documentData)
|
||||
logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}")
|
||||
|
|
@ -1404,6 +1451,12 @@ class ChatObjects:
|
|||
if "timestamp" not in logData:
|
||||
logData["timestamp"] = getUtcTimestamp()
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in logData or not logData["mandateId"]:
|
||||
logData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in logData or not logData["featureInstanceId"]:
|
||||
logData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Add status information if not present
|
||||
if "status" not in logData and "type" in logData:
|
||||
if logData["type"] == "error":
|
||||
|
|
@ -1490,6 +1543,12 @@ class ChatObjects:
|
|||
if "workflowId" not in statData:
|
||||
raise ValueError("workflowId is required in statData")
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in statData or not statData["mandateId"]:
|
||||
statData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in statData or not statData["featureInstanceId"]:
|
||||
statData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Validate the stat data against ChatStat model
|
||||
stat = ChatStat(**statData)
|
||||
|
||||
|
|
@ -1612,7 +1671,7 @@ class ChatObjects:
|
|||
if not automations:
|
||||
return automations
|
||||
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||
|
||||
# Collect all unique user IDs and mandate IDs
|
||||
userIds = set()
|
||||
|
|
@ -1716,7 +1775,7 @@ class ChatObjects:
|
|||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def getAutomationDefinition(self, automationId: str) -> Optional[Dict[str, Any]]:
|
||||
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
|
||||
"""Returns an automation definition by ID if user has access, with computed status."""
|
||||
try:
|
||||
# Use RBAC filtering
|
||||
|
|
@ -1736,21 +1795,25 @@ class ChatObjects:
|
|||
automation["executionLogs"] = []
|
||||
# Enrich with user and mandate names
|
||||
self._enrichAutomationWithUserAndMandate(automation)
|
||||
return automation
|
||||
# Clean metadata fields and return Pydantic model
|
||||
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
|
||||
return AutomationDefinition(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation definition: {str(e)}")
|
||||
return None
|
||||
|
||||
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
|
||||
"""Creates a new automation definition, then triggers sync."""
|
||||
try:
|
||||
# Ensure ID is present
|
||||
if "id" not in automationData or not automationData["id"]:
|
||||
automationData["id"] = str(uuid.uuid4())
|
||||
|
||||
# Ensure mandateId is set
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if "mandateId" not in automationData:
|
||||
automationData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in automationData:
|
||||
automationData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Ensure database connector has correct userId context
|
||||
# The connector should have been initialized with userId, but ensure it's updated
|
||||
|
|
@ -1777,12 +1840,14 @@ class ChatObjects:
|
|||
# Trigger automation change callback (async, don't wait)
|
||||
asyncio.create_task(self._notifyAutomationChanged())
|
||||
|
||||
return createdAutomation
|
||||
# Clean metadata fields and return Pydantic model
|
||||
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
|
||||
return AutomationDefinition(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating automation definition: {str(e)}")
|
||||
raise
|
||||
|
||||
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
|
||||
"""Updates an automation definition, then triggers sync."""
|
||||
try:
|
||||
# Check access
|
||||
|
|
@ -1808,7 +1873,9 @@ class ChatObjects:
|
|||
# Trigger automation change callback (async, don't wait)
|
||||
asyncio.create_task(self._notifyAutomationChanged())
|
||||
|
||||
return updatedAutomation
|
||||
# Clean metadata fields and return Pydantic model
|
||||
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
|
||||
return AutomationDefinition(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating automation definition: {str(e)}")
|
||||
raise
|
||||
|
|
@ -1870,19 +1937,30 @@ class ChatObjects:
|
|||
logger.error(f"Error notifying automation change: {str(e)}")
|
||||
|
||||
|
||||
def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects':
|
||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
|
||||
"""
|
||||
Returns a ChatObjects instance for the current user.
|
||||
Handles initialization of database and records.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
|
||||
"""
|
||||
if not currentUser:
|
||||
raise ValueError("Invalid user context: user is required")
|
||||
|
||||
# Create context key
|
||||
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||
|
||||
# Create context key including featureInstanceId for proper isolation
|
||||
contextKey = f"{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
|
||||
|
||||
# Create new instance if not exists
|
||||
if contextKey not in _chatInterfaces:
|
||||
_chatInterfaces[contextKey] = ChatObjects(currentUser)
|
||||
_chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
else:
|
||||
# Update user context if needed
|
||||
_chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
|
||||
return _chatInterfaces[contextKey]
|
||||
|
|
@ -4,16 +4,136 @@
|
|||
Simple chatbot feature - basic implementation.
|
||||
User input is processed by AI to create list of needed queries.
|
||||
Those queries get streamed back.
|
||||
|
||||
This module also handles feature initialization and RBAC catalog registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
# Feature metadata for RBAC catalog
|
||||
FEATURE_CODE = "chatbot"
|
||||
FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}
|
||||
FEATURE_ICON = "mdi-robot"
|
||||
|
||||
# UI Objects for RBAC catalog
|
||||
UI_OBJECTS = [
|
||||
{
|
||||
"objectKey": "ui.feature.chatbot.conversations",
|
||||
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
|
||||
"meta": {"area": "conversations"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.chatbot.settings",
|
||||
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"},
|
||||
"meta": {"area": "settings"}
|
||||
},
|
||||
]
|
||||
|
||||
# Resource Objects for RBAC catalog
|
||||
RESOURCE_OBJECTS = [
|
||||
{
|
||||
"objectKey": "resource.feature.chatbot.start",
|
||||
"label": {"en": "Start Chatbot", "de": "Chatbot starten", "fr": "Démarrer chatbot"},
|
||||
"meta": {"endpoint": "/api/chatbot/start/stream", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.chatbot.stop",
|
||||
"label": {"en": "Stop Chatbot", "de": "Chatbot stoppen", "fr": "Arrêter chatbot"},
|
||||
"meta": {"endpoint": "/api/chatbot/stop/{workflowId}", "method": "POST"}
|
||||
},
|
||||
]
|
||||
|
||||
# Template roles for this feature
|
||||
TEMPLATE_ROLES = [
|
||||
{
|
||||
"roleLabel": "chatbot-admin",
|
||||
"description": {
|
||||
"en": "Chatbot Administrator - Full access to chatbot settings and all conversations",
|
||||
"de": "Chatbot-Administrator - Vollzugriff auf Chatbot-Einstellungen und alle Konversationen",
|
||||
"fr": "Administrateur chatbot - Accès complet aux paramètres et conversations"
|
||||
},
|
||||
"accessRules": [
|
||||
# Full UI access
|
||||
{"context": "UI", "item": None, "view": True},
|
||||
# Full DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||
# Resource access
|
||||
{"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "chatbot-user",
|
||||
"description": {
|
||||
"en": "Chatbot User - Use chatbot and view own conversations",
|
||||
"de": "Chatbot-Benutzer - Chatbot nutzen und eigene Konversationen einsehen",
|
||||
"fr": "Utilisateur chatbot - Utiliser le chatbot et consulter ses conversations"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to conversations - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
|
||||
# Own DATA access (my level)
|
||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||
# Resource access
|
||||
{"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def getFeatureDefinition():
|
||||
"""Return the feature definition for registration."""
|
||||
return {
|
||||
"code": FEATURE_CODE,
|
||||
"label": FEATURE_LABEL,
|
||||
"icon": FEATURE_ICON
|
||||
}
|
||||
|
||||
|
||||
def getUiObjects():
|
||||
"""Return UI objects for RBAC catalog registration."""
|
||||
return UI_OBJECTS
|
||||
|
||||
|
||||
def getResourceObjects():
|
||||
"""Return resource objects for RBAC catalog registration."""
|
||||
return RESOURCE_OBJECTS
|
||||
|
||||
|
||||
def getTemplateRoles():
|
||||
"""Return template roles for this feature."""
|
||||
return TEMPLATE_ROLES
|
||||
|
||||
|
||||
def registerFeature(catalogService) -> bool:
|
||||
"""Register this feature's RBAC objects in the catalog."""
|
||||
try:
|
||||
for uiObj in UI_OBJECTS:
|
||||
catalogService.registerUiObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=uiObj["objectKey"],
|
||||
label=uiObj["label"],
|
||||
meta=uiObj.get("meta")
|
||||
)
|
||||
|
||||
for resObj in RESOURCE_OBJECTS:
|
||||
catalogService.registerResourceObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=resObj["objectKey"],
|
||||
label=resObj["label"],
|
||||
meta=resObj.get("meta")
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
||||
return False
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument
|
||||
from modules.features.chatbot.datamodelFeatureChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
|
||||
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
|
||||
|
|
@ -62,6 +182,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]:
|
|||
|
||||
async def chatProcess(
|
||||
currentUser: User,
|
||||
mandateId: str,
|
||||
userInput: UserInputRequest,
|
||||
workflowId: Optional[str] = None
|
||||
) -> ChatWorkflow:
|
||||
|
|
@ -76,6 +197,7 @@ async def chatProcess(
|
|||
|
||||
Args:
|
||||
currentUser: Current user
|
||||
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||
userInput: User input request
|
||||
workflowId: Optional workflow ID to continue existing conversation
|
||||
|
||||
|
|
@ -83,8 +205,8 @@ async def chatProcess(
|
|||
ChatWorkflow instance
|
||||
"""
|
||||
try:
|
||||
# Get services
|
||||
services = getServices(currentUser, None)
|
||||
# Get services with mandate context
|
||||
services = getServices(currentUser, None, mandateId=mandateId)
|
||||
interfaceDbChat = services.interfaceDbChat
|
||||
|
||||
# Get event manager and create queue if needed
|
||||
|
|
@ -120,7 +242,7 @@ async def chatProcess(
|
|||
# Create new workflow
|
||||
workflowData = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"mandateId": currentUser.mandateId,
|
||||
"mandateId": mandateId,
|
||||
"status": "running",
|
||||
"name": conversation_name,
|
||||
"currentRound": 1,
|
||||
|
|
@ -333,7 +455,6 @@ async def _emit_log_and_event(
|
|||
# Emit event directly for streaming (using correct signature)
|
||||
if created_log and event_manager:
|
||||
try:
|
||||
from modules.datamodels.datamodelChat import ChatLog
|
||||
# Convert to dict if it's a Pydantic model
|
||||
if hasattr(created_log, "model_dump"):
|
||||
log_dict = created_log.model_dump()
|
||||
|
|
@ -687,12 +808,13 @@ async def _convert_file_ids_to_document_references(
|
|||
# Search database if not found in messages
|
||||
if not document_id:
|
||||
try:
|
||||
from modules.shared.databaseUtils import getRecordsetWithRBAC
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
documents = getRecordsetWithRBAC(
|
||||
services.interfaceDbChat.db,
|
||||
ChatDocument,
|
||||
services.currentUser,
|
||||
recordFilter={"fileId": file_id}
|
||||
services.user,
|
||||
recordFilter={"fileId": file_id},
|
||||
mandateId=services.mandateId
|
||||
)
|
||||
if documents:
|
||||
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()
|
||||
|
|
@ -1141,7 +1263,6 @@ async def _processChatbotMessage(
|
|||
)
|
||||
|
||||
# Retry analysis with empty results context - create NEW analysis with alternative strategies
|
||||
from modules.features.chatbot.chatbotConstants import get_empty_results_retry_instructions
|
||||
|
||||
# Build retry prompt with progressively different strategies
|
||||
empty_count = len(sql_queries)
|
||||
|
|
|
|||
|
|
@ -15,23 +15,22 @@ from fastapi.responses import StreamingResponse
|
|||
from modules.shared.timeUtils import parseTimestamp
|
||||
|
||||
# Import auth modules
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||
from . import interfaceFeatureChatbot as interfaceDbChat
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
|
||||
# Import models
|
||||
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from .datamodelFeatureChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
|
||||
|
||||
# Import chatbot feature
|
||||
from modules.features.chatbot import chatProcess
|
||||
from modules.features.chatbot.eventManager import get_event_manager
|
||||
from . import chatProcess
|
||||
from .eventManager import get_event_manager
|
||||
|
||||
# Import workflow control functions
|
||||
from modules.features.workflow import chatStop
|
||||
from modules.workflows.automation import chatStop
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,8 +42,8 @@ router = APIRouter(
|
|||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
def getServiceChat(currentUser: User):
|
||||
return interfaceDbChatObjects.getInterface(currentUser)
|
||||
def _getServiceChat(context: RequestContext):
|
||||
return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
|
||||
# Chatbot streaming endpoint (SSE)
|
||||
@router.post("/start/stream")
|
||||
|
|
@ -53,7 +52,7 @@ async def stream_chatbot_start(
|
|||
request: Request,
|
||||
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"),
|
||||
userInput: UserInputRequest = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Starts a new chatbot workflow or continues an existing one with SSE streaming.
|
||||
|
|
@ -71,7 +70,7 @@ async def stream_chatbot_start(
|
|||
final_workflow_id = workflowId or userInput.workflowId
|
||||
|
||||
# Start background processing (this will create the workflow and event queue)
|
||||
workflow = await chatProcess(currentUser, userInput, final_workflow_id)
|
||||
workflow = await chatProcess(context.user, str(context.mandateId), userInput, final_workflow_id)
|
||||
|
||||
# Get event queue for the workflow
|
||||
queue = event_manager.get_queue(workflow.id)
|
||||
|
|
@ -83,7 +82,7 @@ async def stream_chatbot_start(
|
|||
"""Async generator for SSE events - pure event-driven streaming (no polling)."""
|
||||
try:
|
||||
# Get interface for initial data and status checks
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = _getServiceChat(context)
|
||||
|
||||
# Get current workflow to check if resuming and get current round
|
||||
current_workflow = interfaceDbChat.getWorkflow(workflow.id)
|
||||
|
|
@ -239,11 +238,11 @@ async def stream_chatbot_start(
|
|||
async def stop_chatbot(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Stops a running chatbot workflow."""
|
||||
try:
|
||||
workflow = await chatStop(currentUser, workflowId)
|
||||
workflow = await chatStop(context.user, workflowId)
|
||||
|
||||
# Emit stopped event to active streams
|
||||
event_manager = get_event_manager()
|
||||
|
|
@ -272,18 +271,18 @@ async def stop_chatbot(
|
|||
async def delete_chatbot(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Deletes a chatbot workflow and its associated data."""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = _getServiceChat(context)
|
||||
|
||||
# Check workflow access and permission using RBAC
|
||||
workflows = getRecordsetWithRBAC(
|
||||
interfaceDbChat.db,
|
||||
ChatWorkflow,
|
||||
currentUser,
|
||||
context.user,
|
||||
recordFilter={"id": workflowId}
|
||||
)
|
||||
if not workflows:
|
||||
|
|
@ -337,7 +336,7 @@ async def get_chatbot_threads(
|
|||
request: Request,
|
||||
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]:
|
||||
"""
|
||||
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
|
||||
|
|
@ -346,7 +345,7 @@ async def get_chatbot_threads(
|
|||
- If workflowId is not provided: Returns a paginated list of all workflows
|
||||
"""
|
||||
try:
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = _getServiceChat(context)
|
||||
|
||||
# If workflowId is provided, return single workflow with chat data
|
||||
if workflowId:
|
||||
|
|
@ -433,7 +432,6 @@ async def get_chatbot_threads(
|
|||
normalized_workflows.append(normalized_wf)
|
||||
|
||||
# Create paginated response
|
||||
from modules.datamodels.datamodelPagination import PaginationMetadata
|
||||
metadata = PaginationMetadata(
|
||||
currentPage=paginationParams.page if paginationParams else 1,
|
||||
pageSize=paginationParams.pageSize if paginationParams else len(workflows),
|
||||
|
|
@ -456,4 +454,3 @@ async def get_chatbot_threads(
|
|||
status_code=500,
|
||||
detail=f"Error getting chatbot threads: {str(e)}"
|
||||
)
|
||||
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Dynamic Options API feature module.
|
||||
Provides dynamic options for frontend select/multiselect fields.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Standard role definitions (fallback if database is not available)
|
||||
STANDARD_ROLES = [
|
||||
{"value": "sysadmin", "label": {"en": "System Administrator", "fr": "Administrateur système"}},
|
||||
{"value": "admin", "label": {"en": "Administrator", "fr": "Administrateur"}},
|
||||
{"value": "user", "label": {"en": "User", "fr": "Utilisateur"}},
|
||||
{"value": "viewer", "label": {"en": "Viewer", "fr": "Visualiseur"}},
|
||||
]
|
||||
|
||||
# Authentication authority options
|
||||
AUTH_AUTHORITY_OPTIONS = [
|
||||
{"value": "local", "label": {"en": "Local", "fr": "Local"}},
|
||||
{"value": "google", "label": {"en": "Google", "fr": "Google"}},
|
||||
{"value": "msft", "label": {"en": "Microsoft", "fr": "Microsoft"}},
|
||||
]
|
||||
|
||||
# Connection status options
|
||||
# Note: Matches ConnectionStatus enum values (active, expired, revoked, pending)
|
||||
# Plus "error" for error states (not in enum but used in UI)
|
||||
CONNECTION_STATUS_OPTIONS = [
|
||||
{"value": "active", "label": {"en": "Active", "fr": "Actif"}},
|
||||
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
|
||||
{"value": "revoked", "label": {"en": "Revoked", "fr": "Révoqué"}},
|
||||
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
|
||||
{"value": "error", "label": {"en": "Error", "fr": "Erreur"}},
|
||||
]
|
||||
|
||||
|
||||
def getOptions(optionsName: str, services, currentUser: Optional[User] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get options for a given options name.
|
||||
|
||||
Args:
|
||||
optionsName: Name of the options set to retrieve (e.g., "user.role", "user.connection")
|
||||
services: Services instance for data access
|
||||
currentUser: Optional current user for context-aware options
|
||||
|
||||
Returns:
|
||||
List of option dictionaries with "value" and "label" keys
|
||||
|
||||
Raises:
|
||||
ValueError: If optionsName is not recognized
|
||||
"""
|
||||
logger.debug(f"getOptions called with optionsName='{optionsName}' (repr: {repr(optionsName)})")
|
||||
optionsNameLower = optionsName.lower()
|
||||
logger.debug(f"optionsNameLower='{optionsNameLower}'")
|
||||
|
||||
if optionsNameLower == "user.role":
|
||||
# Fetch roles from database
|
||||
if currentUser:
|
||||
try:
|
||||
roles = services.interfaceDbApp.getAllRoles()
|
||||
|
||||
# Convert Role objects to options format
|
||||
options = []
|
||||
for role in roles:
|
||||
# Use English description as label, fallback to roleLabel
|
||||
# Handle TextMultilingual object
|
||||
if hasattr(role.description, 'get_text'):
|
||||
# TextMultilingual object
|
||||
label = role.description.get_text('en')
|
||||
elif isinstance(role.description, dict):
|
||||
# Dict format (backward compatibility)
|
||||
label = role.description.get("en", role.roleLabel)
|
||||
else:
|
||||
# Fallback to roleLabel
|
||||
label = role.roleLabel
|
||||
|
||||
options.append({
|
||||
"value": role.roleLabel,
|
||||
"label": label
|
||||
})
|
||||
|
||||
# If no roles in database, return standard roles as fallback
|
||||
if options:
|
||||
return options
|
||||
except Exception as e:
|
||||
logger.warning(f"Error fetching roles from database, using fallback: {e}")
|
||||
|
||||
# Fallback to standard roles if database fetch fails or no user context
|
||||
return STANDARD_ROLES
|
||||
|
||||
elif optionsNameLower == "auth.authority":
|
||||
return AUTH_AUTHORITY_OPTIONS
|
||||
|
||||
elif optionsNameLower == "connection.status":
|
||||
return CONNECTION_STATUS_OPTIONS
|
||||
|
||||
elif optionsNameLower == "user.connection":
|
||||
# Dynamic options: Get user connections from database
|
||||
if not currentUser:
|
||||
return []
|
||||
|
||||
try:
|
||||
connections = services.interfaceDbApp.getUserConnections(currentUser.id)
|
||||
|
||||
return [
|
||||
{
|
||||
"value": conn.id,
|
||||
"label": {
|
||||
"en": f"{conn.authority.value} - {conn.externalUsername or conn.externalId}",
|
||||
"fr": f"{conn.authority.value} - {conn.externalUsername or conn.externalId}"
|
||||
}
|
||||
}
|
||||
for conn in connections
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user connections for options: {e}")
|
||||
return []
|
||||
|
||||
elif optionsNameLower in ("user", "users"):
|
||||
# Dynamic options: Get all users for the current mandate
|
||||
if not currentUser:
|
||||
return []
|
||||
|
||||
try:
|
||||
users = services.interfaceDbApp.getUsersByMandate(currentUser.mandateId)
|
||||
|
||||
# Handle both list and PaginatedResult
|
||||
if hasattr(users, 'items'):
|
||||
userList = users.items
|
||||
else:
|
||||
userList = users
|
||||
|
||||
return [
|
||||
{
|
||||
"value": user.id,
|
||||
"label": user.fullName or user.username or user.email or user.id
|
||||
}
|
||||
for user in userList
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching users for options: {e}")
|
||||
return []
|
||||
|
||||
elif optionsNameLower in ("trusteeorganisation", "trustee.organisation"):
|
||||
# Dynamic options: Get all trustee organisations
|
||||
if not currentUser:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = services.interfaceDbTrustee.getAllOrganisations()
|
||||
|
||||
# Handle PaginatedResult
|
||||
items = result.items if hasattr(result, 'items') else result
|
||||
|
||||
return [
|
||||
{
|
||||
"value": org.get("id") if isinstance(org, dict) else org.id,
|
||||
"label": org.get("label") if isinstance(org, dict) else org.label
|
||||
}
|
||||
for org in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching trustee organisations for options: {e}")
|
||||
return []
|
||||
|
||||
elif optionsNameLower in ("trusteerole", "trustee.role"):
|
||||
# Dynamic options: Get all trustee roles
|
||||
if not currentUser:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = services.interfaceDbTrustee.getAllRoles()
|
||||
|
||||
# Handle PaginatedResult
|
||||
items = result.items if hasattr(result, 'items') else result
|
||||
|
||||
return [
|
||||
{
|
||||
"value": role.get("id") if isinstance(role, dict) else role.id,
|
||||
# TrusteeRole uses 'desc' field, not 'label'
|
||||
"label": role.get("desc", role.get("id")) if isinstance(role, dict) else getattr(role, "desc", role.id)
|
||||
}
|
||||
for role in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching trustee roles for options: {e}")
|
||||
return []
|
||||
|
||||
elif optionsNameLower in ("trusteecontract", "trustee.contract"):
|
||||
# Dynamic options: Get all trustee contracts
|
||||
if not currentUser:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = services.interfaceDbTrustee.getAllContracts()
|
||||
|
||||
# Handle PaginatedResult
|
||||
items = result.items if hasattr(result, 'items') else result
|
||||
|
||||
return [
|
||||
{
|
||||
"value": contract.get("id") if isinstance(contract, dict) else contract.id,
|
||||
"label": contract.get("label") if isinstance(contract, dict) else (contract.get("name") if isinstance(contract, dict) else getattr(contract, "label", getattr(contract, "name", contract.id)))
|
||||
}
|
||||
for contract in items
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching trustee contracts for options: {e}")
|
||||
return []
|
||||
|
||||
else:
|
||||
logger.error(f"Unknown options name: '{optionsName}' (lower: '{optionsNameLower}')")
|
||||
raise ValueError(f"Unknown options name: {optionsName}")
|
||||
|
||||
|
||||
def getAvailableOptionsNames() -> List[str]:
|
||||
"""
|
||||
Get list of all available options names.
|
||||
|
||||
Returns:
|
||||
List of available options names
|
||||
"""
|
||||
return [
|
||||
"user.role",
|
||||
"auth.authority",
|
||||
"connection.status",
|
||||
"user.connection",
|
||||
"User",
|
||||
"TrusteeOrganisation",
|
||||
"TrusteeRole",
|
||||
"TrusteeContract",
|
||||
]
|
||||
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
from modules.services import getInterface as getServices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def start(eventUser) -> None:
|
||||
""" Start feature triggers and background managers
|
||||
|
||||
Args:
|
||||
eventUser: System-level event user for background operations (provided by app.py)
|
||||
"""
|
||||
|
||||
# Feature Workflow (Automation)
|
||||
if eventUser:
|
||||
try:
|
||||
from modules.features.workflow import syncAutomationEvents
|
||||
from modules.shared.callbackRegistry import callbackRegistry
|
||||
|
||||
# Get services for event user (provides access to interfaces)
|
||||
services = getServices(eventUser, None)
|
||||
|
||||
# Register callback for automation changes
|
||||
async def onAutomationChanged(chatInterface):
|
||||
"""Callback triggered when automations are created/updated/deleted."""
|
||||
# Get services for event user to pass to syncAutomationEvents
|
||||
eventServices = getServices(eventUser, None)
|
||||
await syncAutomationEvents(eventServices, eventUser)
|
||||
|
||||
callbackRegistry.register('automation.changed', onAutomationChanged)
|
||||
logger.info("Workflow: Registered change callback")
|
||||
|
||||
# Initial sync on startup - use services
|
||||
await syncAutomationEvents(services, eventUser)
|
||||
logger.info("Workflow: Events synced on startup")
|
||||
except Exception as e:
|
||||
logger.error(f"Workflow: Error setting up events on startup: {str(e)}")
|
||||
# Don't fail startup if automation sync fails
|
||||
|
||||
|
||||
# Feature ...
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def stop(eventUser) -> None:
|
||||
""" Stop feature triggers and background managers
|
||||
|
||||
Args:
|
||||
eventUser: System-level event user (provided by app.py)
|
||||
"""
|
||||
|
||||
# Feature Workflow (Automation)
|
||||
# Callbacks will remain registered (acceptable for shutdown)
|
||||
logger.info("Workflow: Callbacks remain registered (will be cleaned up on shutdown)")
|
||||
|
||||
|
||||
# Feature ...
|
||||
|
||||
return True
|
||||
|
|
@ -11,6 +11,7 @@ from modules.shared.attributeUtils import registerModelLabels
|
|||
class DataNeutraliserConfig(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
||||
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
||||
|
|
@ -22,6 +23,7 @@ registerModelLabels(
|
|||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
|
||||
|
|
@ -33,6 +35,7 @@ registerModelLabels(
|
|||
class DataNeutralizerAttributes(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
featureInstanceId: str = Field(description="ID of the feature instance this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||
|
|
@ -43,6 +46,7 @@ registerModelLabels(
|
|||
{
|
||||
"id": {"en": "ID", "fr": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||
"originalText": {"en": "Original Text", "fr": "Texte original"},
|
||||
"fileId": {"en": "File ID", "fr": "ID de fichier"},
|
||||
240
modules/features/neutralization/interfaceFeatureNeutralizer.py
Normal file
240
modules/features/neutralization/interfaceFeatureNeutralizer.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Database interface for the Neutralizer feature.
|
||||
Handles CRUD operations for neutralization configuration and attributes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from modules.features.neutralization.datamodelFeatureNeutralizer import (
|
||||
DataNeutraliserConfig,
|
||||
DataNeutralizerAttributes,
|
||||
)
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Singleton cache for interface instances
|
||||
_neutralizerInterfaces = {}
|
||||
|
||||
|
||||
class InterfaceFeatureNeutralizer:
|
||||
"""Database interface for Neutralizer feature operations"""
|
||||
|
||||
# Feature code for RBAC objectKey construction
|
||||
FEATURE_CODE = "neutralization"
|
||||
|
||||
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""
|
||||
Initialize the interface with database connection and user context.
|
||||
|
||||
Args:
|
||||
currentUser: Current user object for RBAC
|
||||
mandateId: Current mandate ID
|
||||
featureInstanceId: Current feature instance ID
|
||||
"""
|
||||
self.currentUser = currentUser
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.db = None
|
||||
|
||||
# Initialize database
|
||||
self._initializeDatabase()
|
||||
|
||||
def _initializeDatabase(self):
|
||||
"""Initialize the database connection."""
|
||||
try:
|
||||
# Use same database config pattern as other feature interfaces
|
||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||
dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
|
||||
dbUser = APP_CONFIG.get("DB_USER", "postgres")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
self.db = DatabaseConnector(
|
||||
dbHost=dbHost,
|
||||
dbDatabase=dbDatabase,
|
||||
dbUser=dbUser,
|
||||
dbPassword=dbPassword,
|
||||
dbPort=dbPort,
|
||||
userId=self.userId,
|
||||
)
|
||||
self.db.initDbSystem()
|
||||
logger.debug("Neutralizer database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Neutralizer database: {str(e)}")
|
||||
raise
|
||||
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Sets the user context for the interface."""
|
||||
if not currentUser:
|
||||
logger.info("Initializing interface without user context")
|
||||
return
|
||||
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
|
||||
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
|
||||
"""Get the data neutralization configuration for the current user's mandate"""
|
||||
try:
|
||||
# Use RBAC filtering
|
||||
filteredConfigs = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
DataNeutraliserConfig,
|
||||
self.currentUser,
|
||||
recordFilter={"mandateId": self.mandateId}
|
||||
)
|
||||
|
||||
if not filteredConfigs:
|
||||
return None
|
||||
|
||||
# Filter out database-specific fields
|
||||
configDict = filteredConfigs[0]
|
||||
cleanedConfig = {k: v for k, v in configDict.items() if not k.startswith("_")}
|
||||
return DataNeutraliserConfig(**cleanedConfig)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neutralization config: {str(e)}")
|
||||
return None
|
||||
|
||||
def createOrUpdateNeutralizationConfig(
|
||||
self, configData: Dict[str, Any]
|
||||
) -> DataNeutraliserConfig:
|
||||
"""Create or update the data neutralization configuration"""
|
||||
try:
|
||||
# Check if config already exists
|
||||
existingConfig = self.getNeutralizationConfig()
|
||||
|
||||
if existingConfig:
|
||||
# Update existing config
|
||||
updateData = existingConfig.model_dump()
|
||||
updateData.update(configData)
|
||||
updateData["updatedAt"] = getUtcTimestamp()
|
||||
|
||||
updatedConfig = DataNeutraliserConfig(**updateData)
|
||||
self.db.recordModify(
|
||||
DataNeutraliserConfig, existingConfig.id, updatedConfig
|
||||
)
|
||||
|
||||
return updatedConfig
|
||||
else:
|
||||
# Create new config
|
||||
configData["mandateId"] = self.mandateId
|
||||
configData["userId"] = self.userId
|
||||
|
||||
newConfig = DataNeutraliserConfig(**configData)
|
||||
createdRecord = self.db.recordCreate(DataNeutraliserConfig, newConfig)
|
||||
|
||||
return DataNeutraliserConfig(**createdRecord)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/updating neutralization config: {str(e)}")
|
||||
raise ValueError(f"Failed to create/update neutralization config: {str(e)}")
|
||||
|
||||
def getNeutralizationAttributes(
|
||||
self, fileId: Optional[str] = None
|
||||
) -> List[DataNeutralizerAttributes]:
|
||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||
try:
|
||||
filterDict = {"mandateId": self.mandateId}
|
||||
if fileId:
|
||||
filterDict["fileId"] = fileId
|
||||
|
||||
# Use RBAC filtering
|
||||
filteredAttributes = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
DataNeutralizerAttributes,
|
||||
self.currentUser,
|
||||
recordFilter=filterDict
|
||||
)
|
||||
|
||||
# Filter out database-specific fields
|
||||
cleanedAttributes = []
|
||||
for attr in filteredAttributes:
|
||||
cleanedAttr = {k: v for k, v in attr.items() if not k.startswith("_")}
|
||||
cleanedAttributes.append(cleanedAttr)
|
||||
|
||||
return [
|
||||
DataNeutralizerAttributes(**attr)
|
||||
for attr in cleanedAttributes
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
||||
return []
|
||||
|
||||
def deleteNeutralizationAttributes(self, fileId: str) -> bool:
|
||||
"""Delete all neutralization attributes for a specific file"""
|
||||
try:
|
||||
attributes = self.db.getRecordset(
|
||||
DataNeutralizerAttributes,
|
||||
recordFilter={"mandateId": self.mandateId, "fileId": fileId},
|
||||
)
|
||||
|
||||
for attribute in attributes:
|
||||
self.db.recordDelete(DataNeutralizerAttributes, attribute["id"])
|
||||
|
||||
logger.info(
|
||||
f"Deleted {len(attributes)} neutralization attributes for file {fileId}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting neutralization attributes: {str(e)}")
|
||||
return False
|
||||
|
||||
def getAttributeById(self, attributeId: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a single neutralization attribute by ID"""
|
||||
try:
|
||||
attributes = self.db.getRecordset(
|
||||
DataNeutralizerAttributes,
|
||||
recordFilter={"mandateId": self.mandateId, "id": attributeId}
|
||||
)
|
||||
if attributes:
|
||||
return attributes[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting attribute by ID: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> InterfaceFeatureNeutralizer:
|
||||
"""
|
||||
Factory function to get or create a Neutralizer interface instance.
|
||||
Uses singleton pattern per user context.
|
||||
|
||||
Args:
|
||||
currentUser: Current user for RBAC
|
||||
mandateId: Current mandate ID
|
||||
featureInstanceId: Current feature instance ID
|
||||
|
||||
Returns:
|
||||
InterfaceFeatureNeutralizer instance
|
||||
"""
|
||||
global _neutralizerInterfaces
|
||||
|
||||
if not currentUser:
|
||||
raise ValueError("Valid user context required")
|
||||
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||
|
||||
# Include featureInstanceId in cache key for proper isolation
|
||||
cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
|
||||
|
||||
if cacheKey not in _neutralizerInterfaces:
|
||||
_neutralizerInterfaces[cacheKey] = InterfaceFeatureNeutralizer(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
else:
|
||||
# Update user context if needed
|
||||
_neutralizerInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
|
||||
return _neutralizerInterfaces[cacheKey]
|
||||
|
|
@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
|
|||
from urllib.parse import urlparse, unquote
|
||||
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
|
||||
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
|
||||
from modules.services import getInterface as getServices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -15,9 +15,10 @@ logger = logging.getLogger(__name__)
|
|||
class NeutralizationPlayground:
|
||||
"""Feature/UI wrapper around NeutralizationService for playground & routes."""
|
||||
|
||||
def __init__(self, currentUser: User):
|
||||
def __init__(self, currentUser: User, mandateId: str):
|
||||
self.currentUser = currentUser
|
||||
self.services = getServices(currentUser, None)
|
||||
self.mandateId = mandateId
|
||||
self.services = getServices(currentUser, None, mandateId=mandateId)
|
||||
|
||||
def processText(self, text: str) -> Dict[str, Any]:
|
||||
return self.services.neutralization.processText(text)
|
||||
|
|
@ -81,7 +82,7 @@ class NeutralizationPlayground:
|
|||
'total_attributes': len(allAttributes),
|
||||
'unique_files': len(uniqueFiles),
|
||||
'pattern_counts': patternCounts,
|
||||
'mandate_id': self.currentUser.mandateId if self.currentUser else None,
|
||||
'mandate_id': self.mandateId,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats: {str(e)}")
|
||||
|
|
@ -181,7 +182,6 @@ class SharepointProcessor:
|
|||
|
||||
async def _getSharepointConnection(self, sharepointPath: str = None):
|
||||
try:
|
||||
from modules.datamodels.datamodelUam import UserConnection
|
||||
connections = self.services.interfaceDbApp.db.getRecordset(
|
||||
UserConnection,
|
||||
recordFilter={"userId": self.services.interfaceDbApp.userId}
|
||||
138
modules/features/neutralization/mainNeutralizer.py
Normal file
138
modules/features/neutralization/mainNeutralizer.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Neutralizer Feature Container - Main Module.
|
||||
Handles feature initialization and RBAC catalog registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature metadata
|
||||
FEATURE_CODE = "neutralization"
|
||||
FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}
|
||||
FEATURE_ICON = "mdi-shield-check"
|
||||
|
||||
# UI Objects for RBAC catalog
|
||||
UI_OBJECTS = [
|
||||
{
|
||||
"objectKey": "ui.feature.neutralization.playground",
|
||||
"label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"},
|
||||
"meta": {"area": "playground"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.neutralization.config",
|
||||
"label": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"},
|
||||
"meta": {"area": "config"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.neutralization.attributes",
|
||||
"label": {"en": "Attributes", "de": "Attribute", "fr": "Attributs"},
|
||||
"meta": {"area": "attributes"}
|
||||
},
|
||||
]
|
||||
|
||||
# Resource Objects for RBAC catalog
|
||||
RESOURCE_OBJECTS = [
|
||||
{
|
||||
"objectKey": "resource.feature.neutralization.process.text",
|
||||
"label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"},
|
||||
"meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.neutralization.process.files",
|
||||
"label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"},
|
||||
"meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.neutralization.config.update",
|
||||
"label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"},
|
||||
"meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
|
||||
},
|
||||
]
|
||||
|
||||
# Template roles for this feature
|
||||
TEMPLATE_ROLES = [
|
||||
{
|
||||
"roleLabel": "neutralization-admin",
|
||||
"description": {
|
||||
"en": "Neutralization Administrator - Full access to neutralization settings and data",
|
||||
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
|
||||
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données"
|
||||
},
|
||||
"accessRules": [
|
||||
# Full UI access (all views including admin views)
|
||||
{"context": "UI", "item": None, "view": True},
|
||||
# Full DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "neutralization-analyst",
|
||||
"description": {
|
||||
"en": "Neutralization Analyst - Analyze and process neutralization data",
|
||||
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
|
||||
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to specific views - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
|
||||
# Group-level DATA access (read-only for sensitive config)
|
||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def getFeatureDefinition() -> Dict[str, Any]:
|
||||
"""Return the feature definition for registration."""
|
||||
return {
|
||||
"code": FEATURE_CODE,
|
||||
"label": FEATURE_LABEL,
|
||||
"icon": FEATURE_ICON
|
||||
}
|
||||
|
||||
|
||||
def getUiObjects() -> List[Dict[str, Any]]:
|
||||
"""Return UI objects for RBAC catalog registration."""
|
||||
return UI_OBJECTS
|
||||
|
||||
|
||||
def getResourceObjects() -> List[Dict[str, Any]]:
|
||||
"""Return resource objects for RBAC catalog registration."""
|
||||
return RESOURCE_OBJECTS
|
||||
|
||||
|
||||
def getTemplateRoles() -> List[Dict[str, Any]]:
|
||||
"""Return template roles for this feature."""
|
||||
return TEMPLATE_ROLES
|
||||
|
||||
|
||||
def registerFeature(catalogService) -> bool:
|
||||
"""Register this feature's RBAC objects in the catalog."""
|
||||
try:
|
||||
for uiObj in UI_OBJECTS:
|
||||
catalogService.registerUiObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=uiObj["objectKey"],
|
||||
label=uiObj["label"],
|
||||
meta=uiObj.get("meta")
|
||||
)
|
||||
|
||||
for resObj in RESOURCE_OBJECTS:
|
||||
catalogService.registerResourceObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=resObj["objectKey"],
|
||||
label=resObj["label"],
|
||||
meta=resObj.get("meta")
|
||||
)
|
||||
|
||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
||||
return False
|
||||
|
|
@ -5,12 +5,11 @@ from typing import List, Dict, Any, Optional
|
|||
import logging
|
||||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||
from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
|
||||
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||
from .mainNeutralizePlayground import NeutralizationPlayground
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -32,18 +31,18 @@ router = APIRouter(
|
|||
@limiter.limit("30/minute")
|
||||
async def get_neutralization_config(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> DataNeutraliserConfig:
|
||||
"""Get data neutralization configuration"""
|
||||
try:
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
config = service.getConfig()
|
||||
|
||||
if not config:
|
||||
# Return default config instead of 404
|
||||
return DataNeutraliserConfig(
|
||||
mandateId=currentUser.mandateId,
|
||||
userId=currentUser.id,
|
||||
mandateId=context.mandateId,
|
||||
userId=context.user.id,
|
||||
enabled=True,
|
||||
namesToParse="",
|
||||
sharepointSourcePath="",
|
||||
|
|
@ -66,11 +65,11 @@ async def get_neutralization_config(
|
|||
async def save_neutralization_config(
|
||||
request: Request,
|
||||
config_data: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> DataNeutraliserConfig:
|
||||
"""Save or update data neutralization configuration"""
|
||||
try:
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
config = service.saveConfig(config_data)
|
||||
|
||||
return config
|
||||
|
|
@ -87,7 +86,7 @@ async def save_neutralization_config(
|
|||
async def neutralize_text(
|
||||
request: Request,
|
||||
text_data: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Neutralize text content"""
|
||||
try:
|
||||
|
|
@ -100,7 +99,7 @@ async def neutralize_text(
|
|||
detail="Text content is required"
|
||||
)
|
||||
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
result = service.neutralizeText(text, file_id)
|
||||
|
||||
return result
|
||||
|
|
@ -119,7 +118,7 @@ async def neutralize_text(
|
|||
async def resolve_text(
|
||||
request: Request,
|
||||
text_data: Dict[str, str] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""Resolve UIDs in neutralized text back to original text"""
|
||||
try:
|
||||
|
|
@ -131,7 +130,7 @@ async def resolve_text(
|
|||
detail="Text content is required"
|
||||
)
|
||||
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
resolved_text = service.resolveText(text)
|
||||
|
||||
return {"resolved_text": resolved_text}
|
||||
|
|
@ -150,11 +149,11 @@ async def resolve_text(
|
|||
async def get_neutralization_attributes(
|
||||
request: Request,
|
||||
fileId: Optional[str] = Query(None, description="Filter by file ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[DataNeutralizerAttributes]:
|
||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||
try:
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
attributes = service.getAttributes(fileId)
|
||||
|
||||
return attributes
|
||||
|
|
@ -171,7 +170,7 @@ async def get_neutralization_attributes(
|
|||
async def process_sharepoint_files(
|
||||
request: Request,
|
||||
paths_data: Dict[str, str] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Process files from SharePoint source path and store neutralized files in target path"""
|
||||
try:
|
||||
|
|
@ -184,7 +183,7 @@ async def process_sharepoint_files(
|
|||
detail="Both source and target paths are required"
|
||||
)
|
||||
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
result = await service.processSharepointFiles(source_path, target_path)
|
||||
|
||||
return result
|
||||
|
|
@ -203,7 +202,7 @@ async def process_sharepoint_files(
|
|||
async def batch_process_files(
|
||||
request: Request,
|
||||
files_data: List[Dict[str, Any]] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Process multiple files for neutralization"""
|
||||
try:
|
||||
|
|
@ -213,7 +212,7 @@ async def batch_process_files(
|
|||
detail="Files data is required"
|
||||
)
|
||||
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
result = service.batchNeutralizeFiles(files_data)
|
||||
|
||||
return result
|
||||
|
|
@ -231,11 +230,11 @@ async def batch_process_files(
|
|||
@limiter.limit("30/minute")
|
||||
async def get_neutralization_stats(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get neutralization processing statistics"""
|
||||
try:
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
stats = service.getProcessingStats()
|
||||
|
||||
return stats
|
||||
|
|
@ -252,11 +251,11 @@ async def get_neutralization_stats(
|
|||
async def cleanup_file_attributes(
|
||||
request: Request,
|
||||
fileId: str = Path(..., description="File ID to cleanup attributes for"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""Clean up neutralization attributes for a specific file"""
|
||||
try:
|
||||
service = NeutralizationPlayground(currentUser)
|
||||
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||
success = service.cleanupFileAttributes(fileId)
|
||||
|
||||
if success:
|
||||
|
|
@ -13,14 +13,15 @@ import re
|
|||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||
from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer, getInterface as getNeutralizerInterface
|
||||
|
||||
# Import all necessary classes and functions for neutralization
|
||||
from modules.services.serviceNeutralization.subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
|
||||
from modules.services.serviceNeutralization.subProcessText import TextProcessor, PlainText
|
||||
from modules.services.serviceNeutralization.subProcessList import ListProcessor, TableData
|
||||
from modules.services.serviceNeutralization.subProcessBinary import BinaryProcessor
|
||||
from modules.services.serviceNeutralization.subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
|
||||
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
|
||||
from .subProcessText import TextProcessor, PlainText
|
||||
from .subProcessList import ListProcessor, TableData
|
||||
from .subProcessBinary import BinaryProcessor
|
||||
from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -35,9 +36,18 @@ class NeutralizationService:
|
|||
NamesToParse: List of names to parse and replace (case-insensitive)
|
||||
"""
|
||||
self.services = serviceCenter
|
||||
self.interfaceDbApp = serviceCenter.interfaceDbApp
|
||||
self.interfaceDbComponent = serviceCenter.interfaceDbComponent
|
||||
|
||||
# Create feature-specific interface for neutralizer DB operations
|
||||
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
|
||||
if serviceCenter and serviceCenter.interfaceDbApp:
|
||||
dbApp = serviceCenter.interfaceDbApp
|
||||
self.interfaceNeutralizer = getNeutralizerInterface(
|
||||
currentUser=dbApp.currentUser,
|
||||
mandateId=dbApp.mandateId,
|
||||
featureInstanceId=getattr(dbApp, 'featureInstanceId', None)
|
||||
)
|
||||
|
||||
# Initialize anonymization processors
|
||||
self.NamesToParse = NamesToParse or []
|
||||
self.textProcessor = TextProcessor(NamesToParse)
|
||||
|
|
@ -47,15 +57,15 @@ class NeutralizationService:
|
|||
|
||||
def getConfig(self) -> Optional[DataNeutraliserConfig]:
|
||||
"""Get the neutralization configuration for the current user's mandate"""
|
||||
if not self.interfaceDbApp:
|
||||
if not self.interfaceNeutralizer:
|
||||
return None
|
||||
return self.interfaceDbApp.getNeutralizationConfig()
|
||||
return self.interfaceNeutralizer.getNeutralizationConfig()
|
||||
|
||||
def saveConfig(self, config_data: Dict[str, Any]) -> DataNeutraliserConfig:
|
||||
def saveConfig(self, configData: Dict[str, Any]) -> DataNeutraliserConfig:
|
||||
"""Save or update the neutralization configuration"""
|
||||
if not self.interfaceDbApp:
|
||||
if not self.interfaceNeutralizer:
|
||||
raise ValueError("User context required for saving configuration")
|
||||
return self.interfaceDbApp.createOrUpdateNeutralizationConfig(config_data)
|
||||
return self.interfaceNeutralizer.createOrUpdateNeutralizationConfig(configData)
|
||||
|
||||
# Public API: process text or file
|
||||
|
||||
|
|
@ -125,44 +135,37 @@ class NeutralizationService:
|
|||
return result
|
||||
|
||||
def resolveText(self, text: str) -> str:
|
||||
if not self.interfaceDbApp:
|
||||
if not self.interfaceNeutralizer:
|
||||
return text
|
||||
try:
|
||||
placeholder_pattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
|
||||
matches = re.findall(placeholder_pattern, text)
|
||||
resolved_text = text
|
||||
for placeholder_type, uid in matches:
|
||||
attributes = self.interfaceDbApp.db.getRecordset(
|
||||
DataNeutralizerAttributes,
|
||||
recordFilter={
|
||||
"mandateId": self.interfaceDbApp.mandateId,
|
||||
"id": uid
|
||||
}
|
||||
)
|
||||
if attributes:
|
||||
attribute = attributes[0]
|
||||
placeholder = f"[{placeholder_type}.{uid}]"
|
||||
resolved_text = resolved_text.replace(placeholder, attribute["originalText"])
|
||||
return resolved_text
|
||||
placeholderPattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
|
||||
matches = re.findall(placeholderPattern, text)
|
||||
resolvedText = text
|
||||
for placeholderType, uid in matches:
|
||||
attribute = self.interfaceNeutralizer.getAttributeById(uid)
|
||||
if attribute:
|
||||
placeholder = f"[{placeholderType}.{uid}]"
|
||||
resolvedText = resolvedText.replace(placeholder, attribute["originalText"])
|
||||
return resolvedText
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
def getAttributes(self) -> List[DataNeutralizerAttributes]:
|
||||
"""Get all neutralization attributes for the current user's mandate"""
|
||||
if not self.interfaceDbApp:
|
||||
if not self.interfaceNeutralizer:
|
||||
return []
|
||||
try:
|
||||
# Use the interface method which properly converts dicts to objects
|
||||
return self.interfaceDbApp.getNeutralizationAttributes()
|
||||
return self.interfaceNeutralizer.getNeutralizationAttributes()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
||||
return []
|
||||
|
||||
def deleteNeutralizationAttributes(self, fileId: str) -> bool:
|
||||
"""Delete neutralization attributes for a specific file"""
|
||||
if not self.interfaceDbApp:
|
||||
if not self.interfaceNeutralizer:
|
||||
return False
|
||||
return self.interfaceDbApp.deleteNeutralizationAttributes(fileId)
|
||||
return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
|
||||
|
||||
def _reloadNamesFromConfig(self) -> None:
|
||||
"""Reload names from config and update processors"""
|
||||
|
|
@ -8,7 +8,7 @@ Handles pattern matching and replacement for emails, phones, addresses, IDs and
|
|||
import re
|
||||
import uuid
|
||||
from typing import Dict, List, Tuple, Any
|
||||
from modules.services.serviceNeutralization.subPatterns import DataPatterns, findPatternsInText
|
||||
from .subPatterns import DataPatterns, findPatternsInText
|
||||
|
||||
class StringParser:
|
||||
"""Handles string parsing and replacement operations"""
|
||||
|
|
@ -11,8 +11,8 @@ import xml.etree.ElementTree as ET
|
|||
from typing import Dict, List, Any, Union
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
from modules.services.serviceNeutralization.subParseString import StringParser
|
||||
from modules.services.serviceNeutralization.subPatterns import getPatternForHeader, HeaderPatterns
|
||||
from .subParseString import StringParser
|
||||
from .subPatterns import getPatternForHeader, HeaderPatterns
|
||||
|
||||
@dataclass
|
||||
class TableData:
|
||||
|
|
@ -158,7 +158,7 @@ class ListProcessor:
|
|||
processedAttrs[attrName] = self.string_parser.mapping[attrValue]
|
||||
else:
|
||||
# Check if attribute value matches any data patterns
|
||||
from modules.services.serviceNeutralization.subPatterns import findPatternsInText, DataPatterns
|
||||
from .subPatterns import findPatternsInText, DataPatterns
|
||||
matches = findPatternsInText(attrValue, DataPatterns.patterns)
|
||||
if matches:
|
||||
patternName = matches[0][0]
|
||||
|
|
@ -193,7 +193,7 @@ class ListProcessor:
|
|||
# Skip if already a placeholder
|
||||
if not self.string_parser._isPlaceholder(text):
|
||||
# Check if text matches any patterns
|
||||
from modules.services.serviceNeutralization.subPatterns import findPatternsInText, DataPatterns
|
||||
from .subPatterns import findPatternsInText, DataPatterns
|
||||
patternMatches = findPatternsInText(text, DataPatterns.patterns)
|
||||
|
||||
if patternMatches:
|
||||
|
|
@ -7,7 +7,7 @@ Handles plain text processing without header information
|
|||
|
||||
from typing import Dict, List, Any
|
||||
from dataclasses import dataclass
|
||||
from modules.services.serviceNeutralization.subParseString import StringParser
|
||||
from .subParseString import StringParser
|
||||
|
||||
@dataclass
|
||||
class PlainText:
|
||||
|
|
@ -123,6 +123,12 @@ class Dokument(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance this document belongs to",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
label: str = Field(
|
||||
description="Document label",
|
||||
frontend_type="text",
|
||||
|
|
@ -207,6 +213,12 @@ class Land(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
label: str = Field(
|
||||
description="Country name (e.g. 'Schweiz')",
|
||||
frontend_type="text",
|
||||
|
|
@ -251,6 +263,12 @@ class Kanton(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
label: str = Field(
|
||||
description="Canton name (e.g. 'Zürich')",
|
||||
frontend_type="text",
|
||||
|
|
@ -302,6 +320,12 @@ class Gemeinde(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
label: str = Field(
|
||||
description="Municipality name (e.g. 'Zürich')",
|
||||
frontend_type="text",
|
||||
|
|
@ -359,6 +383,12 @@ class Parzelle(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
|
||||
# Grunddaten
|
||||
label: str = Field(
|
||||
|
|
@ -579,6 +609,12 @@ class Projekt(BaseModel):
|
|||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="ID of the feature instance",
|
||||
frontend_type="text",
|
||||
frontend_readonly=True,
|
||||
frontend_required=False,
|
||||
)
|
||||
label: str = Field(
|
||||
description="Project designation",
|
||||
frontend_type="text",
|
||||
|
|
@ -643,6 +679,7 @@ registerModelLabels(
|
|||
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
|
||||
"statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -653,6 +690,7 @@ registerModelLabels(
|
|||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -662,6 +700,8 @@ registerModelLabels(
|
|||
{
|
||||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
|
||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
|
||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.).
|
|||
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from modules.datamodels.datamodelRealEstate import (
|
||||
from .datamodelFeatureRealEstate import (
|
||||
Projekt,
|
||||
Parzelle,
|
||||
Dokument,
|
||||
|
|
@ -39,11 +39,23 @@ class RealEstateObjects:
|
|||
Handles CRUD operations on Real Estate entities.
|
||||
"""
|
||||
|
||||
def __init__(self, currentUser: Optional[User] = None):
|
||||
"""Initializes the Real Estate Interface."""
|
||||
# Feature code for RBAC objectKey construction
|
||||
# Used to build: data.feature.realestate.{TableName}
|
||||
FEATURE_CODE = "realestate"
|
||||
|
||||
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Initializes the Real Estate Interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
|
||||
"""
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.mandateId = currentUser.mandateId if currentUser else None
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
self.rbac = None # RBAC interface
|
||||
|
||||
# Initialize database
|
||||
|
|
@ -51,17 +63,17 @@ class RealEstateObjects:
|
|||
|
||||
# Set user context if provided
|
||||
if currentUser:
|
||||
self.setUserContext(currentUser)
|
||||
self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||
|
||||
def _initializeDatabase(self):
|
||||
"""Initialize PostgreSQL database connection."""
|
||||
try:
|
||||
# Get database configuration from environment
|
||||
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost")
|
||||
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate")
|
||||
dbUser = APP_CONFIG.get("DB_REALESTATE_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432))
|
||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||
dbDatabase = "poweron_realestate"
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
# Initialize database connector
|
||||
self.db = DatabaseConnector(
|
||||
|
|
@ -101,14 +113,27 @@ class RealEstateObjects:
|
|||
logger.warning(f"Error ensuring supporting tables exist: {e}")
|
||||
# Don't raise - tables will be created on-demand anyway
|
||||
|
||||
def setUserContext(self, currentUser: User):
|
||||
"""Sets the user context for the interface."""
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Sets the user context for the interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
|
||||
"""
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id
|
||||
self.mandateId = currentUser.mandateId
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
|
||||
if not self.userId or not self.mandateId:
|
||||
raise ValueError("Invalid user context: id and mandateId are required")
|
||||
if not self.userId:
|
||||
raise ValueError("Invalid user context: id is required")
|
||||
|
||||
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
|
||||
# Users are NOT assigned to mandates by design - they get mandate context from the request.
|
||||
# sysAdmin users can additionally perform cross-mandate operations.
|
||||
# Without mandateId, operations will be filtered to accessible mandates via RBAC.
|
||||
|
||||
# Initialize RBAC interface
|
||||
if not self.currentUser:
|
||||
|
|
@ -129,9 +154,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Projekt, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create projects")
|
||||
|
||||
# Ensure mandateId is set
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not projekt.mandateId:
|
||||
projekt.mandateId = self.mandateId
|
||||
if not projekt.featureInstanceId:
|
||||
projekt.featureInstanceId = self.featureInstanceId
|
||||
|
||||
# Save to database - use mode='json' to ensure nested Pydantic models are serialized
|
||||
self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
|
||||
|
|
@ -144,7 +171,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Projekt,
|
||||
self.currentUser,
|
||||
recordFilter={"id": projektId}
|
||||
recordFilter={"id": projektId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -158,7 +186,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Projekt,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
return [Projekt(**r) for r in records]
|
||||
|
|
@ -215,8 +244,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Parzelle, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create plots")
|
||||
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not parzelle.mandateId:
|
||||
parzelle.mandateId = self.mandateId
|
||||
if not parzelle.featureInstanceId:
|
||||
parzelle.featureInstanceId = self.featureInstanceId
|
||||
|
||||
# Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized
|
||||
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
|
||||
|
|
@ -229,7 +261,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
recordFilter={"id": parzelleId}
|
||||
recordFilter={"id": parzelleId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -239,14 +272,8 @@ class RealEstateObjects:
|
|||
|
||||
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
|
||||
"""Get all plots matching the filter."""
|
||||
original_gemeinde_value = None
|
||||
|
||||
# Resolve location names to IDs if needed
|
||||
if recordFilter:
|
||||
# Save original value before resolution for fallback search
|
||||
if "kontextGemeinde" in recordFilter:
|
||||
original_gemeinde_value = recordFilter["kontextGemeinde"]
|
||||
|
||||
recordFilter = self._resolveLocationFilters(recordFilter)
|
||||
|
||||
records = getRecordsetWithRBAC(
|
||||
|
|
@ -256,23 +283,6 @@ class RealEstateObjects:
|
|||
recordFilter=recordFilter or {}
|
||||
)
|
||||
|
||||
# Fallback: If no records found and we resolved a Gemeinde name,
|
||||
# try searching with the original name for backwards compatibility
|
||||
# (handles case where data has string names instead of UUIDs)
|
||||
if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter:
|
||||
if recordFilter["kontextGemeinde"] != original_gemeinde_value:
|
||||
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
|
||||
fallback_filter = recordFilter.copy()
|
||||
fallback_filter["kontextGemeinde"] = original_gemeinde_value
|
||||
records = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
recordFilter=fallback_filter
|
||||
)
|
||||
if records:
|
||||
logger.info(f"Found {len(records)} records using original name (legacy data format)")
|
||||
|
||||
return [Parzelle(**r) for r in records]
|
||||
|
||||
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -445,8 +455,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Dokument, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create documents")
|
||||
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not dokument.mandateId:
|
||||
dokument.mandateId = self.mandateId
|
||||
if not dokument.featureInstanceId:
|
||||
dokument.featureInstanceId = self.featureInstanceId
|
||||
|
||||
self.db.recordCreate(Dokument, dokument.model_dump())
|
||||
|
||||
|
|
@ -458,7 +471,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Dokument,
|
||||
self.currentUser,
|
||||
recordFilter={"id": dokumentId}
|
||||
recordFilter={"id": dokumentId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -472,7 +486,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Dokument,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Dokument(**r) for r in records]
|
||||
|
||||
|
|
@ -511,8 +526,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Gemeinde, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create municipalities")
|
||||
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not gemeinde.mandateId:
|
||||
gemeinde.mandateId = self.mandateId
|
||||
if not gemeinde.featureInstanceId:
|
||||
gemeinde.featureInstanceId = self.featureInstanceId
|
||||
|
||||
self.db.recordCreate(Gemeinde, gemeinde.model_dump())
|
||||
|
||||
|
|
@ -524,7 +542,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Gemeinde,
|
||||
self.currentUser,
|
||||
recordFilter={"id": gemeindeId}
|
||||
recordFilter={"id": gemeindeId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -538,7 +557,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Gemeinde,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Gemeinde(**r) for r in records]
|
||||
|
||||
|
|
@ -577,8 +597,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Kanton, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create cantons")
|
||||
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not kanton.mandateId:
|
||||
kanton.mandateId = self.mandateId
|
||||
if not kanton.featureInstanceId:
|
||||
kanton.featureInstanceId = self.featureInstanceId
|
||||
|
||||
self.db.recordCreate(Kanton, kanton.model_dump())
|
||||
|
||||
|
|
@ -590,7 +613,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Kanton,
|
||||
self.currentUser,
|
||||
recordFilter={"id": kantonId}
|
||||
recordFilter={"id": kantonId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -604,7 +628,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Kanton,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Kanton(**r) for r in records]
|
||||
|
||||
|
|
@ -643,8 +668,11 @@ class RealEstateObjects:
|
|||
if not self.checkRbacPermission(Land, "create"):
|
||||
raise PermissionError(f"User {self.userId} cannot create countries")
|
||||
|
||||
# Ensure mandateId and featureInstanceId are set for proper data isolation
|
||||
if not land.mandateId:
|
||||
land.mandateId = self.mandateId
|
||||
if not land.featureInstanceId:
|
||||
land.featureInstanceId = self.featureInstanceId
|
||||
|
||||
self.db.recordCreate(Land, land.model_dump())
|
||||
|
||||
|
|
@ -656,7 +684,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Land,
|
||||
self.currentUser,
|
||||
recordFilter={"id": landId}
|
||||
recordFilter={"id": landId},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
if not records:
|
||||
|
|
@ -670,7 +699,8 @@ class RealEstateObjects:
|
|||
self.db,
|
||||
Land,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter or {}
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Land(**r) for r in records]
|
||||
|
||||
|
|
@ -727,7 +757,9 @@ class RealEstateObjects:
|
|||
permissions = self.rbac.getUserPermissions(
|
||||
self.currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
tableName
|
||||
tableName,
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId
|
||||
)
|
||||
|
||||
if operation == "create":
|
||||
|
|
@ -799,15 +831,27 @@ class RealEstateObjects:
|
|||
raise
|
||||
|
||||
|
||||
def getInterface(currentUser: User) -> RealEstateObjects:
|
||||
def getInterface(currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> RealEstateObjects:
|
||||
"""
|
||||
Factory function to get or create a Real Estate interface instance for a user.
|
||||
Uses singleton pattern per user.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
|
||||
"""
|
||||
userKey = f"{currentUser.id}_{currentUser.mandateId}"
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||
|
||||
# Include featureInstanceId in key for proper isolation
|
||||
userKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
|
||||
|
||||
if userKey not in _realEstateInterfaces:
|
||||
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser)
|
||||
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
else:
|
||||
# Update user context if needed
|
||||
_realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
|
||||
return _realEstateInterfaces[userKey]
|
||||
|
||||
|
|
@ -2,16 +2,154 @@
|
|||
Real Estate feature main logic.
|
||||
Handles database operations with AI-powered natural language processing.
|
||||
Stateless implementation without session management.
|
||||
|
||||
This module also handles feature initialization and RBAC catalog registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
# Feature metadata for RBAC catalog
|
||||
FEATURE_CODE = "realestate"
|
||||
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
|
||||
FEATURE_ICON = "mdi-home-city"
|
||||
|
||||
# UI Objects for RBAC catalog
|
||||
UI_OBJECTS = [
|
||||
{
|
||||
"objectKey": "ui.feature.realestate.projects",
|
||||
"label": {"en": "Projects", "de": "Projekte", "fr": "Projets"},
|
||||
"meta": {"area": "projects"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.realestate.parcels",
|
||||
"label": {"en": "Parcels", "de": "Parzellen", "fr": "Parcelles"},
|
||||
"meta": {"area": "parcels"}
|
||||
},
|
||||
]
|
||||
|
||||
# Resource Objects for RBAC catalog
|
||||
RESOURCE_OBJECTS = [
|
||||
{
|
||||
"objectKey": "resource.feature.realestate.project.create",
|
||||
"label": {"en": "Create Project", "de": "Projekt erstellen", "fr": "Créer projet"},
|
||||
"meta": {"endpoint": "/api/realestate/project", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.realestate.project.delete",
|
||||
"label": {"en": "Delete Project", "de": "Projekt löschen", "fr": "Supprimer projet"},
|
||||
"meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
|
||||
},
|
||||
]
|
||||
|
||||
# Template roles for this feature with AccessRules
|
||||
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
||||
TEMPLATE_ROLES = [
|
||||
{
|
||||
"roleLabel": "realestate-admin",
|
||||
"description": {
|
||||
"en": "Real Estate Administrator - Full access to all property data and settings",
|
||||
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
|
||||
"fr": "Administrateur immobilier - Accès complet aux données et paramètres"
|
||||
},
|
||||
"accessRules": [
|
||||
# Full UI access (all views including admin views)
|
||||
{"context": "UI", "item": None, "view": True},
|
||||
# Full DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||
# Admin resources
|
||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "realestate-manager",
|
||||
"description": {
|
||||
"en": "Real Estate Manager - Manage properties and tenants",
|
||||
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
|
||||
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to main views - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
|
||||
# Group-level DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||
# Resource: create projects
|
||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "realestate-viewer",
|
||||
"description": {
|
||||
"en": "Real Estate Viewer - View property information",
|
||||
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
|
||||
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to view-only views - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
|
||||
# Read-only DATA access (my records)
|
||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def getFeatureDefinition():
|
||||
"""Return the feature definition for registration."""
|
||||
return {
|
||||
"code": FEATURE_CODE,
|
||||
"label": FEATURE_LABEL,
|
||||
"icon": FEATURE_ICON
|
||||
}
|
||||
|
||||
|
||||
def getUiObjects():
|
||||
"""Return UI objects for RBAC catalog registration."""
|
||||
return UI_OBJECTS
|
||||
|
||||
|
||||
def getResourceObjects():
|
||||
"""Return resource objects for RBAC catalog registration."""
|
||||
return RESOURCE_OBJECTS
|
||||
|
||||
|
||||
def getTemplateRoles():
|
||||
"""Return template roles for this feature."""
|
||||
return TEMPLATE_ROLES
|
||||
|
||||
|
||||
def registerFeature(catalogService) -> bool:
|
||||
"""Register this feature's RBAC objects in the catalog."""
|
||||
try:
|
||||
for uiObj in UI_OBJECTS:
|
||||
catalogService.registerUiObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=uiObj["objectKey"],
|
||||
label=uiObj["label"],
|
||||
meta=uiObj.get("meta")
|
||||
)
|
||||
|
||||
for resObj in RESOURCE_OBJECTS:
|
||||
catalogService.registerResourceObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=resObj["objectKey"],
|
||||
label=resObj["label"],
|
||||
meta=resObj.get("meta")
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
||||
return False
|
||||
import json
|
||||
from typing import Optional, Dict, Any, List
|
||||
from fastapi import HTTPException, status
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelRealEstate import (
|
||||
from .datamodelFeatureRealEstate import (
|
||||
Projekt,
|
||||
Parzelle,
|
||||
StatusProzess,
|
||||
|
|
@ -23,7 +161,7 @@ from modules.datamodels.datamodelRealEstate import (
|
|||
Land,
|
||||
)
|
||||
from modules.services import getInterface as getServices
|
||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
||||
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -346,6 +484,7 @@ async def fetch_parcel_polygon_from_swisstopo(
|
|||
|
||||
async def executeDirectQuery(
|
||||
currentUser: User,
|
||||
mandateId: str,
|
||||
queryText: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
|
@ -354,6 +493,7 @@ async def executeDirectQuery(
|
|||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||
queryText: SQL query text
|
||||
parameters: Optional parameters for parameterized queries
|
||||
|
||||
|
|
@ -364,16 +504,15 @@ async def executeDirectQuery(
|
|||
- No session or query history is saved
|
||||
- Query is executed directly and result is returned
|
||||
- For production, validate and sanitize queries before execution
|
||||
- TODO: Implement actual database query execution via interface
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})")
|
||||
logger.debug(f"Query text: {queryText}")
|
||||
if parameters:
|
||||
logger.debug(f"Query parameters: {parameters}")
|
||||
|
||||
# Execute query via Real Estate interface (stateless)
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||
result = realEstateInterface.executeQuery(queryText, parameters)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -529,6 +668,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters:
|
|||
|
||||
async def processNaturalLanguageCommand(
|
||||
currentUser: User,
|
||||
mandateId: str,
|
||||
userInput: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -539,6 +679,7 @@ async def processNaturalLanguageCommand(
|
|||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||
userInput: Natural language command from user
|
||||
|
||||
Returns:
|
||||
|
|
@ -552,11 +693,11 @@ async def processNaturalLanguageCommand(
|
|||
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
|
||||
logger.debug(f"User input: {userInput}")
|
||||
|
||||
# Initialize services for AI access
|
||||
services = getServices(currentUser, workflow=None)
|
||||
services = getServices(currentUser, workflow=None, mandateId=mandateId)
|
||||
aiService = services.ai
|
||||
|
||||
# Step 1: Analyze user intent with AI
|
||||
|
|
@ -567,6 +708,7 @@ async def processNaturalLanguageCommand(
|
|||
# Step 2: Execute CRUD operation based on intent
|
||||
result = await executeIntentBasedOperation(
|
||||
currentUser=currentUser,
|
||||
mandateId=mandateId,
|
||||
intent=intentAnalysis["intent"],
|
||||
entity=intentAnalysis.get("entity"),
|
||||
parameters=intentAnalysis.get("parameters", {}),
|
||||
|
|
@ -839,6 +981,7 @@ IMPORTANT EXTRACTION RULES:
|
|||
|
||||
async def executeIntentBasedOperation(
|
||||
currentUser: User,
|
||||
mandateId: str,
|
||||
intent: str,
|
||||
entity: Optional[str],
|
||||
parameters: Dict[str, Any],
|
||||
|
|
@ -848,6 +991,7 @@ async def executeIntentBasedOperation(
|
|||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
|
||||
entity: Entity type from AI analysis
|
||||
parameters: Extracted parameters from AI analysis
|
||||
|
|
@ -856,8 +1000,8 @@ async def executeIntentBasedOperation(
|
|||
Operation result
|
||||
|
||||
Note:
|
||||
- TODO: Implement actual interface calls once datamodels are ready
|
||||
- Currently returns test responses showing what would be executed
|
||||
- Supports CREATE, READ, UPDATE, DELETE, QUERY intents
|
||||
- Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
|
||||
|
|
@ -872,6 +1016,7 @@ async def executeIntentBasedOperation(
|
|||
|
||||
result = await executeDirectQuery(
|
||||
currentUser=currentUser,
|
||||
mandateId=mandateId,
|
||||
queryText=queryText,
|
||||
parameters=parameters.get("queryParameters"),
|
||||
)
|
||||
|
|
@ -879,12 +1024,12 @@ async def executeIntentBasedOperation(
|
|||
|
||||
elif intent == "CREATE":
|
||||
# Create new entity
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||
|
||||
if entity == "Projekt":
|
||||
# Create Projekt from parameters
|
||||
projekt = Projekt(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=parameters.get("label", ""),
|
||||
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
|
||||
)
|
||||
|
|
@ -898,11 +1043,11 @@ async def executeIntentBasedOperation(
|
|||
elif entity == "Parzelle":
|
||||
# Create Parzelle from parameters
|
||||
# Import Kontext for kontextInformationen
|
||||
from modules.datamodels.datamodelRealEstate import Kontext, GeoPolylinie
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Kontext, GeoPolylinie
|
||||
|
||||
# Build parzelle data with all extracted parameters
|
||||
parzelle_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"mandateId": mandateId,
|
||||
"label": parameters.get("label", ""),
|
||||
}
|
||||
|
||||
|
|
@ -983,9 +1128,9 @@ async def executeIntentBasedOperation(
|
|||
}
|
||||
elif entity == "Gemeinde":
|
||||
# Create Gemeinde from parameters
|
||||
from modules.datamodels.datamodelRealEstate import Gemeinde
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
|
||||
gemeinde = Gemeinde(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=parameters.get("label", ""),
|
||||
id_kanton=parameters.get("id_kanton"),
|
||||
plz=parameters.get("plz"),
|
||||
|
|
@ -998,9 +1143,9 @@ async def executeIntentBasedOperation(
|
|||
}
|
||||
elif entity == "Kanton":
|
||||
# Create Kanton from parameters
|
||||
from modules.datamodels.datamodelRealEstate import Kanton
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
|
||||
kanton = Kanton(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=parameters.get("label", ""),
|
||||
id_land=parameters.get("id_land"),
|
||||
abk=parameters.get("abk"),
|
||||
|
|
@ -1013,9 +1158,9 @@ async def executeIntentBasedOperation(
|
|||
}
|
||||
elif entity == "Land":
|
||||
# Create Land from parameters
|
||||
from modules.datamodels.datamodelRealEstate import Land
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Land
|
||||
land = Land(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=parameters.get("label", ""),
|
||||
abk=parameters.get("abk"),
|
||||
)
|
||||
|
|
@ -1027,9 +1172,9 @@ async def executeIntentBasedOperation(
|
|||
}
|
||||
elif entity == "Dokument":
|
||||
# Create Dokument from parameters
|
||||
from modules.datamodels.datamodelRealEstate import Dokument
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
|
||||
dokument = Dokument(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=parameters.get("label", ""),
|
||||
dokumentReferenz=parameters.get("dokumentReferenz", ""),
|
||||
versionsbezeichnung=parameters.get("versionsbezeichnung"),
|
||||
|
|
@ -1188,7 +1333,7 @@ async def executeIntentBasedOperation(
|
|||
"count": len(parzellen)
|
||||
}
|
||||
elif entity == "Gemeinde":
|
||||
from modules.datamodels.datamodelRealEstate import Gemeinde
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
|
||||
gemeindeId = parameters.get("id")
|
||||
if gemeindeId:
|
||||
gemeinde = realEstateInterface.getGemeinde(gemeindeId)
|
||||
|
|
@ -1209,7 +1354,7 @@ async def executeIntentBasedOperation(
|
|||
"count": len(gemeinden)
|
||||
}
|
||||
elif entity == "Kanton":
|
||||
from modules.datamodels.datamodelRealEstate import Kanton
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
|
||||
kantonId = parameters.get("id")
|
||||
if kantonId:
|
||||
kanton = realEstateInterface.getKanton(kantonId)
|
||||
|
|
@ -1230,7 +1375,7 @@ async def executeIntentBasedOperation(
|
|||
"count": len(kantone)
|
||||
}
|
||||
elif entity == "Land":
|
||||
from modules.datamodels.datamodelRealEstate import Land
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Land
|
||||
landId = parameters.get("id")
|
||||
if landId:
|
||||
land = realEstateInterface.getLand(landId)
|
||||
|
|
@ -1251,7 +1396,7 @@ async def executeIntentBasedOperation(
|
|||
"count": len(laender)
|
||||
}
|
||||
elif entity == "Dokument":
|
||||
from modules.datamodels.datamodelRealEstate import Dokument
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
|
||||
dokumentId = parameters.get("id")
|
||||
if dokumentId:
|
||||
dokument = realEstateInterface.getDokument(dokumentId)
|
||||
|
|
@ -1315,7 +1460,7 @@ async def executeIntentBasedOperation(
|
|||
"result": updated.model_dump()
|
||||
}
|
||||
elif entity == "Gemeinde":
|
||||
from modules.datamodels.datamodelRealEstate import Gemeinde
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
|
||||
gemeindeId = parameters.get("id")
|
||||
if not gemeindeId:
|
||||
raise ValueError("UPDATE operation requires entity ID")
|
||||
|
|
@ -1332,7 +1477,7 @@ async def executeIntentBasedOperation(
|
|||
"result": updated.model_dump()
|
||||
}
|
||||
elif entity == "Kanton":
|
||||
from modules.datamodels.datamodelRealEstate import Kanton
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
|
||||
kantonId = parameters.get("id")
|
||||
if not kantonId:
|
||||
raise ValueError("UPDATE operation requires entity ID")
|
||||
|
|
@ -1349,7 +1494,7 @@ async def executeIntentBasedOperation(
|
|||
"result": updated.model_dump()
|
||||
}
|
||||
elif entity == "Land":
|
||||
from modules.datamodels.datamodelRealEstate import Land
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Land
|
||||
landId = parameters.get("id")
|
||||
if not landId:
|
||||
raise ValueError("UPDATE operation requires entity ID")
|
||||
|
|
@ -1366,7 +1511,7 @@ async def executeIntentBasedOperation(
|
|||
"result": updated.model_dump()
|
||||
}
|
||||
elif entity == "Dokument":
|
||||
from modules.datamodels.datamodelRealEstate import Dokument
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
|
||||
dokumentId = parameters.get("id")
|
||||
if not dokumentId:
|
||||
raise ValueError("UPDATE operation requires entity ID")
|
||||
|
|
@ -1412,7 +1557,7 @@ async def executeIntentBasedOperation(
|
|||
"success": success
|
||||
}
|
||||
elif entity == "Gemeinde":
|
||||
from modules.datamodels.datamodelRealEstate import Gemeinde
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
|
||||
gemeindeId = parameters.get("id")
|
||||
if not gemeindeId:
|
||||
raise ValueError("DELETE operation requires entity ID")
|
||||
|
|
@ -1424,7 +1569,7 @@ async def executeIntentBasedOperation(
|
|||
"success": success
|
||||
}
|
||||
elif entity == "Kanton":
|
||||
from modules.datamodels.datamodelRealEstate import Kanton
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
|
||||
kantonId = parameters.get("id")
|
||||
if not kantonId:
|
||||
raise ValueError("DELETE operation requires entity ID")
|
||||
|
|
@ -1436,7 +1581,7 @@ async def executeIntentBasedOperation(
|
|||
"success": success
|
||||
}
|
||||
elif entity == "Land":
|
||||
from modules.datamodels.datamodelRealEstate import Land
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Land
|
||||
landId = parameters.get("id")
|
||||
if not landId:
|
||||
raise ValueError("DELETE operation requires entity ID")
|
||||
|
|
@ -1448,7 +1593,7 @@ async def executeIntentBasedOperation(
|
|||
"success": success
|
||||
}
|
||||
elif entity == "Dokument":
|
||||
from modules.datamodels.datamodelRealEstate import Dokument
|
||||
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
|
||||
dokumentId = parameters.get("id")
|
||||
if not dokumentId:
|
||||
raise ValueError("DELETE operation requires entity ID")
|
||||
|
|
@ -1474,6 +1619,7 @@ async def executeIntentBasedOperation(
|
|||
|
||||
async def create_project_with_parcel_data(
|
||||
currentUser: User,
|
||||
mandateId: str,
|
||||
projekt_label: str,
|
||||
parzellen_data: List[Dict[str, Any]],
|
||||
status_prozess: Optional[str] = None,
|
||||
|
|
@ -1483,6 +1629,7 @@ async def create_project_with_parcel_data(
|
|||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||
projekt_label: Label for the Projekt
|
||||
parzellen_data: List of dictionaries containing parcel information from request
|
||||
status_prozess: Optional project status (defaults to "Eingang")
|
||||
|
|
@ -1496,8 +1643,8 @@ async def create_project_with_parcel_data(
|
|||
try:
|
||||
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
|
||||
|
||||
# Get interface
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
# Get interface with mandate context
|
||||
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||
|
||||
# Validate required fields
|
||||
if not projekt_label:
|
||||
|
|
@ -1587,7 +1734,7 @@ async def create_project_with_parcel_data(
|
|||
|
||||
# Check if Parzelle with this label already exists
|
||||
existing_parzellen = realEstateInterface.getParzellen(
|
||||
recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId}
|
||||
recordFilter={"label": parcel_label, "mandateId": mandateId}
|
||||
)
|
||||
|
||||
if existing_parzellen and len(existing_parzellen) > 0:
|
||||
|
|
@ -1630,7 +1777,7 @@ async def create_project_with_parcel_data(
|
|||
if not laender:
|
||||
logger.info("Creating Land 'Schweiz'")
|
||||
land = Land(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label="Schweiz",
|
||||
abk="CH"
|
||||
)
|
||||
|
|
@ -1648,7 +1795,7 @@ async def create_project_with_parcel_data(
|
|||
logger.info(f"Kanton '{canton_abk}' not found, creating it")
|
||||
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
|
||||
kanton = Kanton(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=kanton_label,
|
||||
abk=canton_abk,
|
||||
id_land=land.id
|
||||
|
|
@ -1668,7 +1815,7 @@ async def create_project_with_parcel_data(
|
|||
if not gemeinden:
|
||||
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
|
||||
gemeinde = Gemeinde(
|
||||
mandateId=currentUser.mandateId,
|
||||
mandateId=mandateId,
|
||||
label=municipality_name,
|
||||
id_kanton=kanton.id,
|
||||
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
|
||||
|
|
@ -1837,7 +1984,7 @@ async def create_project_with_parcel_data(
|
|||
|
||||
# Build Parzelle data
|
||||
parzelle_create_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"mandateId": mandateId,
|
||||
"label": parcel_label, # Use the label we determined earlier for uniqueness check
|
||||
"parzellenAliasTags": alias_tags,
|
||||
"eigentuemerschaft": None,
|
||||
|
|
@ -1979,7 +2126,7 @@ async def create_project_with_parcel_data(
|
|||
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
|
||||
|
||||
projekt_create_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"mandateId": mandateId,
|
||||
"label": projekt_label,
|
||||
"statusProzess": status_prozess_enum,
|
||||
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter
|
||||
|
|
|
|||
|
|
@ -10,12 +10,11 @@ from typing import Optional, Dict, Any, List, Union
|
|||
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
|
||||
|
||||
# Import auth modules
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
|
||||
# Import models
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
|
||||
from modules.datamodels.datamodelRealEstate import (
|
||||
from .datamodelFeatureRealEstate import (
|
||||
Projekt,
|
||||
Parzelle,
|
||||
Dokument,
|
||||
|
|
@ -27,10 +26,10 @@ from modules.datamodels.datamodelRealEstate import (
|
|||
)
|
||||
|
||||
# Import interfaces
|
||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
||||
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||
|
||||
# Import feature logic for AI-powered commands
|
||||
from modules.features.realEstate.mainRealEstate import (
|
||||
from .mainRealEstate import (
|
||||
processNaturalLanguageCommand,
|
||||
create_project_with_parcel_data,
|
||||
)
|
||||
|
|
@ -63,7 +62,7 @@ router = APIRouter(
|
|||
async def process_command(
|
||||
request: Request,
|
||||
userInput: str = Body(..., embed=True, description="Natural language command"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process natural language command and execute corresponding CRUD operation.
|
||||
|
|
@ -73,9 +72,9 @@ async def process_command(
|
|||
|
||||
Example user inputs:
|
||||
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
|
||||
- "Zeige mir alle Projekte in Zürich"
|
||||
- "Zeige mir alle Projekte in Zuerich"
|
||||
- "Aktualisiere Projekt XYZ mit Status 'Planung'"
|
||||
- "Lösche Parzelle ABC"
|
||||
- "Loesche Parzelle ABC"
|
||||
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
||||
|
||||
Headers:
|
||||
|
|
@ -93,7 +92,7 @@ async def process_command(
|
|||
# Validate CSRF token (middleware also checks, but explicit validation for better error messages)
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
|
|
@ -101,7 +100,7 @@ async def process_command(
|
|||
|
||||
# Basic CSRF token format validation
|
||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}")
|
||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
|
|
@ -111,18 +110,19 @@ async def process_command(
|
|||
try:
|
||||
int(csrf_token, 16)
|
||||
except ValueError:
|
||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
)
|
||||
|
||||
logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})")
|
||||
logger.debug(f"User input: {userInput}")
|
||||
|
||||
# Process natural language command with AI
|
||||
result = await processNaturalLanguageCommand(
|
||||
currentUser=currentUser,
|
||||
currentUser=context.user,
|
||||
mandateId=str(context.mandateId),
|
||||
userInput=userInput
|
||||
)
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ async def process_command(
|
|||
@limiter.limit("120/minute")
|
||||
async def get_available_tables(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all available real estate tables.
|
||||
|
|
@ -164,7 +164,7 @@ async def get_available_tables(
|
|||
# Validate CSRF token if provided
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
|
|
@ -172,7 +172,7 @@ async def get_available_tables(
|
|||
|
||||
# Basic CSRF token format validation
|
||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}")
|
||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
|
|
@ -182,13 +182,13 @@ async def get_available_tables(
|
|||
try:
|
||||
int(csrf_token, 16)
|
||||
except ValueError:
|
||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
)
|
||||
|
||||
logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})")
|
||||
|
||||
# Define available tables with descriptions
|
||||
tables = [
|
||||
|
|
@ -245,7 +245,7 @@ async def get_table_data(
|
|||
request: Request,
|
||||
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[Dict[str, Any]]:
|
||||
"""
|
||||
Get all data from a specific real estate table with optional pagination.
|
||||
|
|
@ -273,7 +273,7 @@ async def get_table_data(
|
|||
# Validate CSRF token if provided
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
|
|
@ -281,7 +281,7 @@ async def get_table_data(
|
|||
|
||||
# Basic CSRF token format validation
|
||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
|
|
@ -291,13 +291,13 @@ async def get_table_data(
|
|||
try:
|
||||
int(csrf_token, 16)
|
||||
except ValueError:
|
||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
)
|
||||
|
||||
logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})")
|
||||
|
||||
# Map table names to model classes and getter methods
|
||||
table_mapping = {
|
||||
|
|
@ -317,7 +317,7 @@ async def get_table_data(
|
|||
)
|
||||
|
||||
# Get interface and fetch data
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
model_class, method_name = table_mapping[table]
|
||||
getter_method = getattr(realEstateInterface, method_name)
|
||||
|
||||
|
|
@ -399,7 +399,7 @@ async def create_table_record(
|
|||
request: Request,
|
||||
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
||||
data: Dict[str, Any] = Body(..., description="Record data to create"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new record in a specific real estate table.
|
||||
|
|
@ -442,7 +442,7 @@ async def create_table_record(
|
|||
# Validate CSRF token
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
|
|
@ -450,7 +450,7 @@ async def create_table_record(
|
|||
|
||||
# Basic CSRF token format validation
|
||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
|
|
@ -460,7 +460,7 @@ async def create_table_record(
|
|||
try:
|
||||
int(csrf_token, 16)
|
||||
except ValueError:
|
||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid CSRF token format"
|
||||
|
|
@ -468,7 +468,7 @@ async def create_table_record(
|
|||
|
||||
# Special handling for Projekt with parcel data
|
||||
if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
|
||||
logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Creating Projekt with parcel data for user {context.user.id} (mandate: {context.mandateId})")
|
||||
|
||||
# Extract fields
|
||||
label = data.get("label")
|
||||
|
|
@ -491,7 +491,7 @@ async def create_table_record(
|
|||
detail="parzellen must be an array"
|
||||
)
|
||||
elif "parzelle" in data:
|
||||
# Single parcel (backward compatibility)
|
||||
# Single parcel
|
||||
parzelle_data = data.get("parzelle")
|
||||
if parzelle_data:
|
||||
parzellen_data = [parzelle_data]
|
||||
|
|
@ -505,7 +505,8 @@ async def create_table_record(
|
|||
# Use helper function to create project with parcel data
|
||||
try:
|
||||
result = await create_project_with_parcel_data(
|
||||
currentUser=currentUser,
|
||||
currentUser=context.user,
|
||||
mandateId=str(context.mandateId),
|
||||
projekt_label=label,
|
||||
parzellen_data=parzellen_data,
|
||||
status_prozess=status_prozess,
|
||||
|
|
@ -524,7 +525,7 @@ async def create_table_record(
|
|||
)
|
||||
|
||||
# Standard handling for other tables or Projekt without parcel data
|
||||
logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Creating record in table '{table}' for user {context.user.id} (mandate: {context.mandateId})")
|
||||
logger.debug(f"Record data: {data}")
|
||||
|
||||
# Map table names to model classes and create methods
|
||||
|
|
@ -545,13 +546,13 @@ async def create_table_record(
|
|||
)
|
||||
|
||||
# Get interface
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
model_class, method_name = table_mapping[table]
|
||||
create_method = getattr(realEstateInterface, method_name)
|
||||
|
||||
# Ensure mandateId is set (will be set by interface if missing)
|
||||
# Ensure mandateId is set from context
|
||||
if "mandateId" not in data:
|
||||
data["mandateId"] = currentUser.mandateId
|
||||
data["mandateId"] = str(context.mandateId) if context.mandateId else None
|
||||
|
||||
# Create model instance from data
|
||||
try:
|
||||
|
|
@ -596,7 +597,7 @@ async def search_parcel(
|
|||
request: Request,
|
||||
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
|
||||
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Search for parcel information by address or coordinates.
|
||||
|
|
@ -614,50 +615,18 @@ async def search_parcel(
|
|||
|
||||
Headers:
|
||||
- X-CSRF-Token: CSRF token (required for security)
|
||||
|
||||
Examples:
|
||||
- GET /api/realestate/parcel/search?location=2600000,1200000
|
||||
- GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern
|
||||
- GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true
|
||||
|
||||
Returns:
|
||||
{
|
||||
"parcel": {
|
||||
"id": "823",
|
||||
"egrid": "CH294676423526",
|
||||
"number": "823",
|
||||
"name": "823",
|
||||
"identnd": "BE0200000042",
|
||||
"canton": "BE",
|
||||
"municipality_code": 351,
|
||||
"municipality_name": "Bern",
|
||||
"address": "Bundesplatz 3 3011 Bern",
|
||||
"plz": "3011",
|
||||
"perimeter": {...},
|
||||
"area_m2": 1234.56,
|
||||
"centroid": {"x": 2600000, "y": 1200000},
|
||||
"geoportal_url": "https://...",
|
||||
"realestate_type": null
|
||||
},
|
||||
"map_view": {
|
||||
"center": {"x": 2600000, "y": 1200000},
|
||||
"zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...},
|
||||
"geometry_geojson": {...}
|
||||
},
|
||||
"adjacent_parcels": [...] // Optional (only if include_adjacent=true)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Validate CSRF token
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
)
|
||||
|
||||
logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}")
|
||||
logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}")
|
||||
|
||||
# Initialize connector
|
||||
connector = SwissTopoMapServerConnector()
|
||||
|
|
@ -762,15 +731,14 @@ async def search_parcel(
|
|||
# Basic municipality lookup for common codes
|
||||
common_municipalities = {
|
||||
351: "Bern",
|
||||
261: "Zürich",
|
||||
6621: "Genève",
|
||||
261: "Zuerich",
|
||||
6621: "Geneve",
|
||||
2701: "Basel",
|
||||
5586: "Lausanne",
|
||||
1061: "Luzern",
|
||||
3203: "Winterthur",
|
||||
230: "St. Gallen",
|
||||
5192: "Lugano",
|
||||
351: "Bern",
|
||||
1367: "Schwyz"
|
||||
}
|
||||
|
||||
|
|
@ -944,7 +912,7 @@ async def add_parcel_to_project(
|
|||
request: Request,
|
||||
projekt_id: str = Path(..., description="Projekt ID"),
|
||||
body: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a parcel to an existing project.
|
||||
|
|
@ -961,7 +929,7 @@ async def add_parcel_to_project(
|
|||
|
||||
Option 2 - Create new parcel from location:
|
||||
{
|
||||
"location": "Hauptstrasse 42, 8000 Zürich"
|
||||
"location": "Hauptstrasse 42, 8000 Zuerich"
|
||||
}
|
||||
|
||||
Option 3 - Create new parcel with custom data:
|
||||
|
|
@ -988,7 +956,7 @@ async def add_parcel_to_project(
|
|||
# Validate CSRF token
|
||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||
if not csrf_token:
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}")
|
||||
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||
|
|
@ -1008,15 +976,16 @@ async def add_parcel_to_project(
|
|||
detail="Invalid CSRF token format"
|
||||
)
|
||||
|
||||
logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||
logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})")
|
||||
|
||||
# Get interface
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
|
||||
# Fetch existing Projekt
|
||||
projekte = realEstateInterface.getProjekte(
|
||||
recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId}
|
||||
)
|
||||
# Fetch existing Projekt - use mandateId from context
|
||||
recordFilter = {"id": projekt_id}
|
||||
if context.mandateId:
|
||||
recordFilter["mandateId"] = str(context.mandateId)
|
||||
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter)
|
||||
if not projekte:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -1034,9 +1003,10 @@ async def add_parcel_to_project(
|
|||
# Option 1: Link existing parcel
|
||||
if parcel_id:
|
||||
logger.info(f"Linking existing parcel {parcel_id}")
|
||||
parcels = realEstateInterface.getParzellen(
|
||||
recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId}
|
||||
)
|
||||
parcelFilter = {"id": parcel_id}
|
||||
if context.mandateId:
|
||||
parcelFilter["mandateId"] = str(context.mandateId)
|
||||
parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter)
|
||||
if not parcels:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -1062,9 +1032,9 @@ async def add_parcel_to_project(
|
|||
extracted_attributes = connector.extract_parcel_attributes(parcel_data)
|
||||
attributes = parcel_data.get("attributes", {})
|
||||
|
||||
# Create Parzelle
|
||||
# Create Parzelle with mandateId from context
|
||||
parzelle_create_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"mandateId": str(context.mandateId) if context.mandateId else None,
|
||||
"label": extracted_attributes.get("label") or attributes.get("number") or "Unknown",
|
||||
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
|
||||
"eigentuemerschaft": None,
|
||||
|
|
@ -1111,7 +1081,7 @@ async def add_parcel_to_project(
|
|||
# Option 3: Create from custom data
|
||||
elif parcel_data_dict:
|
||||
logger.info(f"Creating parcel from custom data")
|
||||
parcel_data_dict["mandateId"] = currentUser.mandateId
|
||||
parcel_data_dict["mandateId"] = str(context.mandateId) if context.mandateId else None
|
||||
parzelle_instance = Parzelle(**parcel_data_dict)
|
||||
parzelle = realEstateInterface.createParzelle(parzelle_instance)
|
||||
|
||||
|
|
@ -1150,4 +1120,3 @@ async def add_parcel_to_project(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error adding parcel to project: {str(e)}"
|
||||
)
|
||||
|
||||
|
|
@ -44,6 +44,15 @@ class TrusteeOrganisation(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector:
|
||||
# _createdAt, _modifiedAt, _createdBy, _modifiedBy
|
||||
|
||||
|
|
@ -56,6 +65,7 @@ registerModelLabels(
|
|||
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -87,6 +97,15 @@ class TrusteeRole(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
||||
|
||||
|
|
@ -97,6 +116,7 @@ registerModelLabels(
|
|||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -118,7 +138,7 @@ class TrusteeAccess(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeOrganisation"
|
||||
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||
}
|
||||
)
|
||||
roleId: str = Field(
|
||||
|
|
@ -127,7 +147,7 @@ class TrusteeAccess(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeRole"
|
||||
"frontend_options": "/api/trustee/{instanceId}/roles/options"
|
||||
}
|
||||
)
|
||||
userId: str = Field(
|
||||
|
|
@ -136,7 +156,7 @@ class TrusteeAccess(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "User"
|
||||
"frontend_options": "/api/users/options"
|
||||
}
|
||||
)
|
||||
contractId: Optional[str] = Field(
|
||||
|
|
@ -146,7 +166,7 @@ class TrusteeAccess(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": False,
|
||||
"frontend_options": "TrusteeContract",
|
||||
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||
"frontend_depends_on": "organisationId"
|
||||
}
|
||||
)
|
||||
|
|
@ -159,6 +179,15 @@ class TrusteeAccess(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
||||
|
||||
|
|
@ -172,6 +201,7 @@ registerModelLabels(
|
|||
"userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
|
||||
"contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -193,7 +223,7 @@ class TrusteeContract(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False, # Editable at creation, then readonly
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeOrganisation"
|
||||
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||
}
|
||||
)
|
||||
label: str = Field(
|
||||
|
|
@ -222,6 +252,15 @@ class TrusteeContract(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
||||
|
||||
|
|
@ -234,12 +273,21 @@ registerModelLabels(
|
|||
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
|
||||
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TrusteeDocument(BaseModel):
|
||||
"""Contains document references and receipts for bookings."""
|
||||
"""Contains document references for bookings.
|
||||
|
||||
Documents reference files in the central Files table via fileId.
|
||||
This allows file content to be stored once and referenced by multiple features.
|
||||
|
||||
Note: organisationId and contractId removed as per architecture decision:
|
||||
- The feature instance IS the organisation
|
||||
- Contracts are eliminated from the model
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique document ID",
|
||||
|
|
@ -249,30 +297,11 @@ class TrusteeDocument(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
organisationId: str = Field(
|
||||
description="Reference to TrusteeOrganisation.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeOrganisation"
|
||||
}
|
||||
)
|
||||
contractId: str = Field(
|
||||
description="Reference to TrusteeContract.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeContract",
|
||||
"frontend_depends_on": "organisationId"
|
||||
}
|
||||
)
|
||||
documentData: Optional[bytes] = Field(
|
||||
fileId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The file content (binary)",
|
||||
description="Reference to central Files table (Files.id)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "file",
|
||||
"frontend_type": "file_reference",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": False
|
||||
}
|
||||
|
|
@ -292,23 +321,47 @@ class TrusteeDocument(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": [
|
||||
{"value": "application/pdf", "label": {"en": "PDF", "fr": "PDF", "de": "PDF"}},
|
||||
{"value": "image/jpeg", "label": {"en": "JPEG", "fr": "JPEG", "de": "JPEG"}},
|
||||
{"value": "image/png", "label": {"en": "PNG", "fr": "PNG", "de": "PNG"}},
|
||||
{"value": "application/octet-stream", "label": {"en": "Other", "fr": "Autre", "de": "Andere"}},
|
||||
]
|
||||
"frontend_options": "/api/trustee/mime-types/options"
|
||||
}
|
||||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
sourceType: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mandate ID",
|
||||
description="Source type (e.g., 'sharepoint', 'upload', 'email')",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
sourceLocation: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Original source location (e.g., SharePoint path)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mandate ID (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
||||
|
||||
|
|
@ -317,18 +370,24 @@ registerModelLabels(
|
|||
{"en": "Document", "fr": "Document", "de": "Dokument"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
|
||||
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
|
||||
"documentData": {"en": "Document Data", "fr": "Données du document", "de": "Dokumentdaten"},
|
||||
"fileId": {"en": "File Reference", "fr": "Référence du fichier", "de": "Datei-Referenz"},
|
||||
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
|
||||
"documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
|
||||
"sourceType": {"en": "Source Type", "fr": "Type de source", "de": "Quelltyp"},
|
||||
"sourceLocation": {"en": "Source Location", "fr": "Emplacement source", "de": "Quellort"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TrusteePosition(BaseModel):
|
||||
"""Contains booking positions (expense entries)."""
|
||||
"""Contains booking positions (expense entries).
|
||||
|
||||
Note: organisationId and contractId removed as per architecture decision:
|
||||
- The feature instance IS the organisation
|
||||
- Contracts are eliminated from the model
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique position ID",
|
||||
|
|
@ -338,25 +397,6 @@ class TrusteePosition(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
organisationId: str = Field(
|
||||
description="Reference to TrusteeOrganisation.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeOrganisation"
|
||||
}
|
||||
)
|
||||
contractId: str = Field(
|
||||
description="Reference to TrusteeContract.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeContract",
|
||||
"frontend_depends_on": "organisationId"
|
||||
}
|
||||
)
|
||||
valuta: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Value date (ISO format: YYYY-MM-DD)",
|
||||
|
|
@ -470,11 +510,22 @@ class TrusteePosition(BaseModel):
|
|||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mandate ID",
|
||||
description="Mandate ID (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
|
@ -485,8 +536,6 @@ registerModelLabels(
|
|||
{"en": "Position", "fr": "Position", "de": "Position"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
|
||||
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
|
||||
"valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
|
||||
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
|
||||
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
|
||||
|
|
@ -499,12 +548,18 @@ registerModelLabels(
|
|||
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
|
||||
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TrusteePositionDocument(BaseModel):
|
||||
"""Cross-reference table linking positions to documents (many-to-many)."""
|
||||
"""Cross-reference table linking positions to documents (many-to-many).
|
||||
|
||||
Note: organisationId and contractId removed as per architecture decision:
|
||||
- The feature instance IS the organisation
|
||||
- Contracts are eliminated from the model
|
||||
"""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Unique link ID",
|
||||
|
|
@ -514,33 +569,13 @@ class TrusteePositionDocument(BaseModel):
|
|||
"frontend_required": False
|
||||
}
|
||||
)
|
||||
organisationId: str = Field(
|
||||
description="Reference to TrusteeOrganisation.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeOrganisation"
|
||||
}
|
||||
)
|
||||
contractId: str = Field(
|
||||
description="Reference to TrusteeContract.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeContract",
|
||||
"frontend_depends_on": "organisationId"
|
||||
}
|
||||
)
|
||||
documentId: str = Field(
|
||||
description="Reference to TrusteeDocument.id",
|
||||
json_schema_extra={
|
||||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteeDocument",
|
||||
"frontend_depends_on": "contractId"
|
||||
"frontend_options": "/api/trustee/{instanceId}/documents/options"
|
||||
}
|
||||
)
|
||||
positionId: str = Field(
|
||||
|
|
@ -549,17 +584,27 @@ class TrusteePositionDocument(BaseModel):
|
|||
"frontend_type": "select",
|
||||
"frontend_readonly": False,
|
||||
"frontend_required": True,
|
||||
"frontend_options": "TrusteePosition",
|
||||
"frontend_depends_on": "contractId"
|
||||
"frontend_options": "/api/trustee/{instanceId}/positions/options"
|
||||
}
|
||||
)
|
||||
mandateId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mandate ID",
|
||||
description="Mandate ID (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
featureInstanceId: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
||||
json_schema_extra={
|
||||
"frontend_type": "text",
|
||||
"frontend_readonly": True,
|
||||
"frontend_required": False,
|
||||
"frontend_hidden": True
|
||||
}
|
||||
)
|
||||
# System attributes are automatically set by DatabaseConnector
|
||||
|
|
@ -570,10 +615,9 @@ registerModelLabels(
|
|||
{"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
|
||||
{
|
||||
"id": {"en": "ID", "fr": "ID", "de": "ID"},
|
||||
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
|
||||
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
|
||||
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
|
||||
"positionId": {"en": "Position", "fr": "Position", "de": "Position"},
|
||||
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
440
modules/features/trustee/mainTrustee.py
Normal file
440
modules/features/trustee/mainTrustee.py
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Trustee Feature Container - Main Module.
|
||||
Handles feature initialization and RBAC catalog registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Feature metadata
|
||||
FEATURE_CODE = "trustee"
|
||||
FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}
|
||||
FEATURE_ICON = "mdi-briefcase"
|
||||
|
||||
# UI Objects for RBAC catalog
|
||||
# Note: organisations and contracts removed - feature instance = organisation
|
||||
UI_OBJECTS = [
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.dashboard",
|
||||
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
|
||||
"meta": {"area": "dashboard"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.positions",
|
||||
"label": {"en": "Positions", "de": "Positionen", "fr": "Positions"},
|
||||
"meta": {"area": "positions"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.documents",
|
||||
"label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"},
|
||||
"meta": {"area": "documents"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.position-documents",
|
||||
"label": {"en": "Position Documents", "de": "Positions-Dokumente", "fr": "Documents de position"},
|
||||
"meta": {"area": "position-documents"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.expense-import",
|
||||
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
|
||||
"meta": {"area": "expense-import"}
|
||||
},
|
||||
{
|
||||
"objectKey": "ui.feature.trustee.instance-roles",
|
||||
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"},
|
||||
"meta": {"area": "admin", "admin_only": True}
|
||||
},
|
||||
]
|
||||
|
||||
# DATA Objects for RBAC catalog (tables/entities)
|
||||
# Used for AccessRules on data-level permissions
|
||||
DATA_OBJECTS = [
|
||||
{
|
||||
"objectKey": "data.feature.trustee.TrusteePosition",
|
||||
"label": {"en": "Position", "de": "Position", "fr": "Position"},
|
||||
"meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]}
|
||||
},
|
||||
{
|
||||
"objectKey": "data.feature.trustee.TrusteeDocument",
|
||||
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
|
||||
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
|
||||
},
|
||||
{
|
||||
"objectKey": "data.feature.trustee.TrusteePositionDocument",
|
||||
"label": {"en": "Position-Document Assignment", "de": "Position-Dokument Zuordnung", "fr": "Assignation Position-Document"},
|
||||
"meta": {"table": "TrusteePositionDocument", "fields": ["id", "positionId", "documentId"]}
|
||||
},
|
||||
{
|
||||
"objectKey": "data.feature.trustee.*",
|
||||
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
|
||||
"meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"}
|
||||
},
|
||||
]
|
||||
|
||||
# Resource Objects for RBAC catalog
|
||||
# Note: organisations and contracts removed - feature instance = organisation
|
||||
RESOURCE_OBJECTS = [
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.documents.create",
|
||||
"label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.documents.update",
|
||||
"label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.documents.delete",
|
||||
"label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.positions.create",
|
||||
"label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.positions.update",
|
||||
"label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.positions.delete",
|
||||
"label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"}
|
||||
},
|
||||
{
|
||||
"objectKey": "resource.feature.trustee.instance-roles.manage",
|
||||
"label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"},
|
||||
"meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
|
||||
},
|
||||
]
|
||||
|
||||
# Template roles for this feature with AccessRules
|
||||
# Each role defines default UI and DATA permissions
|
||||
# Note: UI item=None means ALL views, specific items restrict to named views
|
||||
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
||||
TEMPLATE_ROLES = [
|
||||
{
|
||||
"roleLabel": "trustee-admin",
|
||||
"description": {
|
||||
"en": "Trustee Administrator - Full access to all trustee data and settings",
|
||||
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
|
||||
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
|
||||
},
|
||||
"accessRules": [
|
||||
# Full UI access (all views including admin views)
|
||||
{"context": "UI", "item": None, "view": True},
|
||||
# Full DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||
# Admin resource: manage instance roles
|
||||
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "trustee-accountant",
|
||||
"description": {
|
||||
"en": "Trustee Accountant - Manage accounting and financial data",
|
||||
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
|
||||
"fr": "Comptable fiduciaire - Gérer les données comptables et financières"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to main views (not admin views) - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
||||
# Group-level DATA access
|
||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||
]
|
||||
},
|
||||
{
|
||||
"roleLabel": "trustee-client",
|
||||
"description": {
|
||||
"en": "Trustee Client - View own accounting data and documents",
|
||||
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
|
||||
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
|
||||
},
|
||||
"accessRules": [
|
||||
# UI access to main views only (read-only focus) - vollqualifizierte ObjectKeys
|
||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
|
||||
# Own records only (MY level) - explizite Regeln pro Tabelle
|
||||
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||
{"context": "DATA", "item": "data.feature.trustee.TrusteePositionDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def getFeatureDefinition() -> Dict[str, Any]:
|
||||
"""Return the feature definition for registration."""
|
||||
return {
|
||||
"code": FEATURE_CODE,
|
||||
"label": FEATURE_LABEL,
|
||||
"icon": FEATURE_ICON
|
||||
}
|
||||
|
||||
|
||||
def getUiObjects() -> List[Dict[str, Any]]:
|
||||
"""Return UI objects for RBAC catalog registration."""
|
||||
return UI_OBJECTS
|
||||
|
||||
|
||||
def getResourceObjects() -> List[Dict[str, Any]]:
|
||||
"""Return resource objects for RBAC catalog registration."""
|
||||
return RESOURCE_OBJECTS
|
||||
|
||||
|
||||
def getTemplateRoles() -> List[Dict[str, Any]]:
|
||||
"""Return template roles for this feature."""
|
||||
return TEMPLATE_ROLES
|
||||
|
||||
|
||||
def getDataObjects() -> List[Dict[str, Any]]:
|
||||
"""Return DATA objects for RBAC catalog registration."""
|
||||
return DATA_OBJECTS
|
||||
|
||||
|
||||
def registerFeature(catalogService) -> bool:
|
||||
"""
|
||||
Register this feature's RBAC objects in the catalog.
|
||||
|
||||
Args:
|
||||
catalogService: The RBAC catalog service instance
|
||||
|
||||
Returns:
|
||||
True if registration was successful
|
||||
"""
|
||||
try:
|
||||
# Register UI objects
|
||||
for uiObj in UI_OBJECTS:
|
||||
catalogService.registerUiObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=uiObj["objectKey"],
|
||||
label=uiObj["label"],
|
||||
meta=uiObj.get("meta")
|
||||
)
|
||||
|
||||
# Register Resource objects
|
||||
for resObj in RESOURCE_OBJECTS:
|
||||
catalogService.registerResourceObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=resObj["objectKey"],
|
||||
label=resObj["label"],
|
||||
meta=resObj.get("meta")
|
||||
)
|
||||
|
||||
# Register DATA objects (tables/entities)
|
||||
for dataObj in DATA_OBJECTS:
|
||||
catalogService.registerDataObject(
|
||||
featureCode=FEATURE_CODE,
|
||||
objectKey=dataObj["objectKey"],
|
||||
label=dataObj["label"],
|
||||
meta=dataObj.get("meta")
|
||||
)
|
||||
|
||||
# Sync template roles to database (with AccessRules)
|
||||
_syncTemplateRolesToDb()
|
||||
|
||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _syncTemplateRolesToDb() -> int:
|
||||
"""
|
||||
Sync template roles and their AccessRules to the database.
|
||||
Creates global template roles (mandateId=None) if they don't exist.
|
||||
|
||||
Returns:
|
||||
Number of roles created/updated
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
db = rootInterface.db
|
||||
|
||||
# Get existing template roles for this feature
|
||||
existingRoles = db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"featureCode": FEATURE_CODE, "mandateId": None}
|
||||
)
|
||||
existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles}
|
||||
|
||||
createdCount = 0
|
||||
for roleTemplate in TEMPLATE_ROLES:
|
||||
roleLabel = roleTemplate["roleLabel"]
|
||||
|
||||
if roleLabel in existingRoleLabels:
|
||||
roleId = existingRoleLabels[roleLabel]
|
||||
logger.debug(f"Template role '{roleLabel}' already exists with ID {roleId}")
|
||||
|
||||
# Ensure AccessRules exist for this role
|
||||
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
|
||||
else:
|
||||
# Create new template role
|
||||
newRole = Role(
|
||||
roleLabel=roleLabel,
|
||||
description=roleTemplate.get("description", {}),
|
||||
featureCode=FEATURE_CODE,
|
||||
mandateId=None, # Global template
|
||||
featureInstanceId=None,
|
||||
isSystemRole=False
|
||||
)
|
||||
createdRole = db.recordCreate(Role, newRole.model_dump())
|
||||
roleId = createdRole.get("id")
|
||||
|
||||
# Create AccessRules for this role
|
||||
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
|
||||
|
||||
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
|
||||
createdCount += 1
|
||||
|
||||
if createdCount > 0:
|
||||
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
|
||||
|
||||
# Repair instance-specific roles that are missing AccessRules
|
||||
_repairInstanceRolesAccessRules(db, existingRoleLabels)
|
||||
|
||||
return createdCount
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _repairInstanceRolesAccessRules(db, templateRoleLabels: Dict[str, str]) -> int:
|
||||
"""
|
||||
Repair instance-specific roles by copying AccessRules from their template roles.
|
||||
This ensures instance roles created before AccessRules were defined get updated.
|
||||
|
||||
Args:
|
||||
db: Database connector
|
||||
templateRoleLabels: Dict mapping roleLabel to template role ID
|
||||
|
||||
Returns:
|
||||
Number of instance roles repaired
|
||||
"""
|
||||
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
||||
|
||||
repairedCount = 0
|
||||
|
||||
# Get all instance-specific roles for this feature (mandateId is NOT None)
|
||||
allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE})
|
||||
instanceRoles = [r for r in allRoles if r.get("mandateId") is not None]
|
||||
|
||||
for instanceRole in instanceRoles:
|
||||
roleLabel = instanceRole.get("roleLabel")
|
||||
instanceRoleId = instanceRole.get("id")
|
||||
|
||||
# Find matching template role
|
||||
templateRoleId = templateRoleLabels.get(roleLabel)
|
||||
if not templateRoleId:
|
||||
continue
|
||||
|
||||
# Check if instance role has AccessRules
|
||||
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId})
|
||||
if existingRules:
|
||||
continue # Already has rules, skip
|
||||
|
||||
# Copy AccessRules from template role
|
||||
templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId})
|
||||
if not templateRules:
|
||||
continue # Template has no rules
|
||||
|
||||
for rule in templateRules:
|
||||
newRule = AccessRule(
|
||||
roleId=instanceRoleId,
|
||||
context=rule.get("context"),
|
||||
item=rule.get("item"),
|
||||
view=rule.get("view", False),
|
||||
read=rule.get("read"),
|
||||
create=rule.get("create"),
|
||||
update=rule.get("update"),
|
||||
delete=rule.get("delete"),
|
||||
)
|
||||
db.recordCreate(AccessRule, newRule.model_dump())
|
||||
|
||||
logger.info(f"Repaired instance role '{roleLabel}' (ID: {instanceRoleId}): copied {len(templateRules)} AccessRules from template")
|
||||
repairedCount += 1
|
||||
|
||||
if repairedCount > 0:
|
||||
logger.info(f"Feature '{FEATURE_CODE}': Repaired {repairedCount} instance roles with missing AccessRules")
|
||||
|
||||
return repairedCount
|
||||
|
||||
|
||||
def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
|
||||
"""
|
||||
Ensure AccessRules exist for a role based on templates.
|
||||
|
||||
Args:
|
||||
db: Database connector
|
||||
roleId: Role ID
|
||||
ruleTemplates: List of rule templates
|
||||
|
||||
Returns:
|
||||
Number of rules created
|
||||
"""
|
||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
||||
|
||||
# Get existing rules for this role
|
||||
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
||||
|
||||
# Create a set of existing rule signatures to avoid duplicates
|
||||
existingSignatures = set()
|
||||
for rule in existingRules:
|
||||
sig = (rule.get("context"), rule.get("item"))
|
||||
existingSignatures.add(sig)
|
||||
|
||||
createdCount = 0
|
||||
for template in ruleTemplates:
|
||||
context = template.get("context", "UI")
|
||||
item = template.get("item")
|
||||
sig = (context, item)
|
||||
|
||||
if sig in existingSignatures:
|
||||
continue
|
||||
|
||||
# Map context string to enum
|
||||
if context == "UI":
|
||||
contextEnum = AccessRuleContext.UI
|
||||
elif context == "DATA":
|
||||
contextEnum = AccessRuleContext.DATA
|
||||
elif context == "RESOURCE":
|
||||
contextEnum = AccessRuleContext.RESOURCE
|
||||
else:
|
||||
contextEnum = context
|
||||
|
||||
newRule = AccessRule(
|
||||
roleId=roleId,
|
||||
context=contextEnum,
|
||||
item=item,
|
||||
view=template.get("view", False),
|
||||
read=template.get("read"),
|
||||
create=template.get("create"),
|
||||
update=template.get("update"),
|
||||
delete=template.get("delete"),
|
||||
)
|
||||
db.recordCreate(AccessRule, newRule.model_dump())
|
||||
createdCount += 1
|
||||
|
||||
if createdCount > 0:
|
||||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||
|
||||
return createdCount
|
||||
1574
modules/features/trustee/routeFeatureTrustee.py
Normal file
1574
modules/features/trustee/routeFeatureTrustee.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,10 @@
|
|||
"""
|
||||
Interface to the Gateway system.
|
||||
Manages users and mandates for authentication.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- User gehört nicht mehr direkt zu einem Mandanten
|
||||
- mandateId wird aus Request-Context übergeben (X-Mandate-Id Header)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -32,11 +36,15 @@ from modules.datamodels.datamodelRbac import (
|
|||
)
|
||||
from modules.datamodels.datamodelUam import AccessLevel
|
||||
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus
|
||||
from modules.datamodels.datamodelNeutralizer import (
|
||||
DataNeutraliserConfig,
|
||||
DataNeutralizerAttributes,
|
||||
)
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||
from modules.datamodels.datamodelMembership import (
|
||||
UserMandate,
|
||||
UserMandateRole,
|
||||
FeatureAccess,
|
||||
FeatureAccessRole,
|
||||
)
|
||||
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -61,7 +69,7 @@ class AppObjects:
|
|||
# Initialize variables
|
||||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.mandateId = currentUser.mandateId if currentUser else None
|
||||
self.mandateId = None # mandateId comes from setUserContext, not from User
|
||||
|
||||
# Initialize database
|
||||
self._initializeDatabase()
|
||||
|
|
@ -73,25 +81,40 @@ class AppObjects:
|
|||
if currentUser:
|
||||
self.setUserContext(currentUser)
|
||||
|
||||
def setUserContext(self, currentUser: User):
|
||||
"""Sets the user context for the interface."""
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||
"""
|
||||
Sets the user context for the interface.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||
- isSysAdmin User brauchen kein mandateId für System-Operationen
|
||||
|
||||
Args:
|
||||
currentUser: User object
|
||||
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
|
||||
"""
|
||||
if not currentUser:
|
||||
logger.info("Initializing interface without user context")
|
||||
return
|
||||
|
||||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id
|
||||
self.mandateId = currentUser.mandateId
|
||||
|
||||
# mandateId comes from parameter only
|
||||
self.mandateId = mandateId
|
||||
|
||||
if not self.userId or not self.mandateId:
|
||||
raise ValueError("Invalid user context: id and mandateId are required")
|
||||
# Validate: userId is always required
|
||||
if not self.userId:
|
||||
raise ValueError("Invalid user context: id is required")
|
||||
|
||||
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
|
||||
# Users are NOT assigned to mandates by design - they get mandate context from the request.
|
||||
# sysAdmin users can additionally perform cross-mandate operations.
|
||||
|
||||
# Add language settings
|
||||
self.userLanguage = currentUser.language # Default user language
|
||||
|
||||
# Initialize RBAC interface
|
||||
if not currentUser:
|
||||
raise ValueError("User context is required for RBAC")
|
||||
# Pass self.db as dbApp since this interface uses DbApp database
|
||||
self.rbac = RbacClass(self.db, dbApp=self.db)
|
||||
|
||||
|
|
@ -110,11 +133,11 @@ class AppObjects:
|
|||
"""Initializes the database connection directly."""
|
||||
try:
|
||||
# Get configuration values with defaults
|
||||
dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data")
|
||||
dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app")
|
||||
dbUser = APP_CONFIG.get("DB_APP_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432))
|
||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||
dbDatabase = "poweron_app"
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
# Create database connector directly
|
||||
self.db = DatabaseConnector(
|
||||
|
|
@ -204,7 +227,8 @@ class AppObjects:
|
|||
permissions = self.rbac.getUserPermissions(
|
||||
self.currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
tableName
|
||||
tableName,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if operation == "create":
|
||||
|
|
@ -574,26 +598,51 @@ class AppObjects:
|
|||
logger.error(f"Error getting user by ID: {str(e)}")
|
||||
return None
|
||||
|
||||
def _getUserForAuthentication(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get user record by username for authentication purposes.
|
||||
|
||||
SECURITY NOTE: This method bypasses RBAC intentionally because:
|
||||
1. Users are NOT mandate-bound (Multi-Tenant Design)
|
||||
2. Authentication must work regardless of mandate context
|
||||
3. RBAC filtering for User table requires mandate context which doesn't exist at login time
|
||||
|
||||
This method should ONLY be used for authentication flows.
|
||||
For all other user queries, use getUserByUsername() which applies RBAC.
|
||||
|
||||
Returns:
|
||||
Full UserInDB record as dict, or None if not found
|
||||
"""
|
||||
try:
|
||||
users = self.db.getRecordset(UserInDB, recordFilter={"username": username})
|
||||
if not users:
|
||||
return None
|
||||
return users[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user for authentication: {str(e)}")
|
||||
return None
|
||||
|
||||
def authenticateLocalUser(self, username: str, password: str) -> Optional[User]:
|
||||
"""Authenticates a user by username and password using local authentication."""
|
||||
# Clear the users table from cache and reload it
|
||||
"""
|
||||
Authenticates a user by username and password using local authentication.
|
||||
|
||||
SECURITY NOTE: Uses _getUserForAuthentication() which bypasses RBAC.
|
||||
This is intentional because users are mandate-independent.
|
||||
"""
|
||||
# Get full user record directly (bypasses RBAC - see _getUserForAuthentication docstring)
|
||||
userRecord = self._getUserForAuthentication(username)
|
||||
|
||||
# Get user by username
|
||||
user = self.getUserByUsername(username)
|
||||
|
||||
if not user:
|
||||
if not userRecord:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Check if the user is enabled
|
||||
if not user.enabled:
|
||||
if not userRecord.get("enabled", True):
|
||||
raise ValueError("User is disabled")
|
||||
|
||||
# Verify that the user has local authentication enabled
|
||||
if user.authenticationAuthority != AuthAuthority.LOCAL:
|
||||
authAuthority = userRecord.get("authenticationAuthority", AuthAuthority.LOCAL)
|
||||
if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value:
|
||||
raise ValueError("User does not have local authentication enabled")
|
||||
|
||||
# Get the full user record with password hash for verification
|
||||
userRecord = self.db.getRecordset(UserInDB, recordFilter={"id": user.id})[0]
|
||||
|
||||
# Check if user has a reset token set (password reset required)
|
||||
if userRecord.get("resetToken"):
|
||||
|
|
@ -605,7 +654,12 @@ class AppObjects:
|
|||
if not self._verifyPassword(password, userRecord["hashedPassword"]):
|
||||
raise ValueError("Invalid password")
|
||||
|
||||
return user
|
||||
# Return clean User object (without password hash and internal fields)
|
||||
cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
|
||||
# Ensure roleLabels is always a list
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
return User(**cleanedUser)
|
||||
|
||||
def createUser(
|
||||
self,
|
||||
|
|
@ -615,13 +669,17 @@ class AppObjects:
|
|||
fullName: str = None,
|
||||
language: str = "en",
|
||||
enabled: bool = True,
|
||||
roleLabels: List[str] = None,
|
||||
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
|
||||
externalId: str = None,
|
||||
externalUsername: str = None,
|
||||
externalEmail: str = None,
|
||||
isSysAdmin: bool = False,
|
||||
) -> User:
|
||||
"""Create a new user with optional external connection"""
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
Note: Role assignment is done via createUserMandate(), not via User fields.
|
||||
"""
|
||||
try:
|
||||
# Ensure username is a string
|
||||
username = str(username).strip()
|
||||
|
|
@ -638,28 +696,17 @@ class AppObjects:
|
|||
if not password.strip():
|
||||
raise ValueError("Password cannot be empty")
|
||||
|
||||
# Ensure mandateId is set - use self.mandateId or default mandate
|
||||
mandateId = self.mandateId
|
||||
if not mandateId:
|
||||
mandateId = self._getDefaultMandateId()
|
||||
logger.warning(f"Using default mandate ID {mandateId} for new user {username}")
|
||||
|
||||
# Default roleLabels to ["user"] if not provided
|
||||
if roleLabels is None or not roleLabels:
|
||||
roleLabels = ["user"]
|
||||
|
||||
# Create user data using UserInDB model
|
||||
# Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole
|
||||
userData = UserInDB(
|
||||
username=username,
|
||||
email=email,
|
||||
fullName=fullName,
|
||||
language=language,
|
||||
mandateId=mandateId,
|
||||
enabled=enabled,
|
||||
roleLabels=roleLabels,
|
||||
isSysAdmin=isSysAdmin,
|
||||
authenticationAuthority=authenticationAuthority,
|
||||
hashedPassword=self._getPasswordHash(password) if password else None,
|
||||
connections=[],
|
||||
)
|
||||
|
||||
# Create user record
|
||||
|
|
@ -695,8 +742,16 @@ class AppObjects:
|
|||
logger.error(f"Unexpected error creating user: {str(e)}")
|
||||
raise ValueError(f"Failed to create user: {str(e)}")
|
||||
|
||||
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User]) -> User:
|
||||
"""Update a user's information"""
|
||||
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User:
|
||||
"""Update a user's information.
|
||||
|
||||
Args:
|
||||
userId: ID of the user to update
|
||||
updateData: User data to update (dict or User model)
|
||||
allowSysAdminChange: If True, allows changing isSysAdmin field.
|
||||
Only set to True when called by a SysAdmin explicitly
|
||||
changing another user's admin status.
|
||||
"""
|
||||
try:
|
||||
# Get user
|
||||
user = self.getUser(userId)
|
||||
|
|
@ -711,26 +766,20 @@ class AppObjects:
|
|||
|
||||
# Remove id field from updateDict if present - we'll use userId from parameter
|
||||
updateDict.pop("id", None)
|
||||
|
||||
# Ensure mandateId is set - if missing or None, use default mandate
|
||||
if "mandateId" not in updateDict or not updateDict.get("mandateId"):
|
||||
if not user.mandateId:
|
||||
# User has no mandateId, set to default
|
||||
defaultMandateId = self._getDefaultMandateId()
|
||||
updateDict["mandateId"] = defaultMandateId
|
||||
logger.warning(f"Setting default mandate ID {defaultMandateId} for user {userId}")
|
||||
else:
|
||||
# Keep existing mandateId if update doesn't provide one
|
||||
updateDict["mandateId"] = user.mandateId
|
||||
|
||||
# SECURITY: Protect sensitive fields from being overwritten by profile updates.
|
||||
# These fields should only be changed explicitly by admins, not through
|
||||
# profile forms where they might be sent as default values (e.g., isSysAdmin=False).
|
||||
protectedFields = ["isSysAdmin"]
|
||||
if not allowSysAdminChange:
|
||||
for field in protectedFields:
|
||||
updateDict.pop(field, None)
|
||||
|
||||
# Update user data using model
|
||||
updatedData = user.model_dump()
|
||||
updatedData.update(updateDict)
|
||||
# Ensure ID matches userId parameter
|
||||
updatedData["id"] = userId
|
||||
# Ensure mandateId is set in final data
|
||||
if not updatedData.get("mandateId"):
|
||||
updatedData["mandateId"] = self._getDefaultMandateId()
|
||||
updatedUser = User(**updatedData)
|
||||
|
||||
# Update user record
|
||||
|
|
@ -1184,10 +1233,10 @@ class AppObjects:
|
|||
The created UserConnection object
|
||||
"""
|
||||
try:
|
||||
# Get the user
|
||||
user = self.getUser(userId)
|
||||
if not user:
|
||||
raise ValueError(f"User not found: {userId}")
|
||||
# Note: User verification is skipped here because:
|
||||
# 1. The caller (route) already has an authenticated currentUser
|
||||
# 2. Users should always be able to create connections for themselves
|
||||
# 3. getUser() uses RBAC filtering which may fail for users without UserInDB view permissions
|
||||
|
||||
# Create new connection with all required fields
|
||||
connection = UserConnection(
|
||||
|
|
@ -1305,13 +1354,13 @@ class AppObjects:
|
|||
|
||||
return Mandate(**filteredMandates[0])
|
||||
|
||||
def createMandate(self, name: str, language: str = "en") -> Mandate:
|
||||
def createMandate(self, name: str, description: str = None, enabled: bool = True) -> Mandate:
|
||||
"""Creates a new mandate if user has permission."""
|
||||
if not self.checkRbacPermission(Mandate, "create"):
|
||||
raise PermissionError("No permission to create mandates")
|
||||
|
||||
# Create mandate data using model
|
||||
mandateData = Mandate(name=name, language=language)
|
||||
mandateData = Mandate(name=name, description=description, enabled=enabled)
|
||||
|
||||
# Create mandate record
|
||||
createdRecord = self.db.recordCreate(Mandate, mandateData)
|
||||
|
|
@ -1382,6 +1431,325 @@ class AppObjects:
|
|||
logger.error(f"Error deleting mandate: {str(e)}")
|
||||
raise ValueError(f"Failed to delete mandate: {str(e)}")
|
||||
|
||||
# ============================================
|
||||
# User-Mandate Membership Methods (Multi-Tenant)
|
||||
# ============================================
|
||||
|
||||
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
|
||||
"""
|
||||
Get UserMandate record for a user in a specific mandate.
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
mandateId: Mandate ID
|
||||
|
||||
Returns:
|
||||
UserMandate object or None
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
if not records:
|
||||
return None
|
||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||
return UserMandate(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting UserMandate: {e}")
|
||||
return None
|
||||
|
||||
def getUserMandates(self, userId: str) -> List[UserMandate]:
|
||||
"""
|
||||
Get all mandates a user is member of.
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
|
||||
Returns:
|
||||
List of UserMandate objects
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "enabled": True}
|
||||
)
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
result.append(UserMandate(**cleanedRecord))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting UserMandates: {e}")
|
||||
return []
|
||||
|
||||
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
|
||||
"""
|
||||
Create a UserMandate record (add user to mandate).
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
mandateId: Mandate ID
|
||||
roleIds: Optional list of role IDs to assign
|
||||
|
||||
Returns:
|
||||
Created UserMandate object
|
||||
"""
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = self.getUserMandate(userId, mandateId)
|
||||
if existing:
|
||||
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
|
||||
|
||||
# Create UserMandate
|
||||
userMandate = UserMandate(
|
||||
userId=userId,
|
||||
mandateId=mandateId,
|
||||
enabled=True
|
||||
)
|
||||
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
|
||||
|
||||
# Assign roles via junction table
|
||||
if roleIds and createdRecord:
|
||||
userMandateId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
userMandateRole = UserMandateRole(
|
||||
userMandateId=userMandateId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return UserMandate(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating UserMandate: {e}")
|
||||
raise ValueError(f"Failed to create UserMandate: {e}")
|
||||
|
||||
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
|
||||
"""
|
||||
Delete a UserMandate record (remove user from mandate).
|
||||
CASCADE will delete UserMandateRole entries.
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
mandateId: Mandate ID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
try:
|
||||
existing = self.getUserMandate(userId, mandateId)
|
||||
if not existing:
|
||||
return False
|
||||
|
||||
return self.db.recordDelete(UserMandate, existing.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting UserMandate: {e}")
|
||||
raise ValueError(f"Failed to delete UserMandate: {e}")
|
||||
|
||||
def getRoleIdsForUserMandate(self, userMandateId: str) -> List[str]:
|
||||
"""
|
||||
Get all role IDs assigned to a UserMandate.
|
||||
|
||||
Args:
|
||||
userMandateId: UserMandate ID
|
||||
|
||||
Returns:
|
||||
List of role IDs
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId}
|
||||
)
|
||||
return [r.get("roleId") for r in records if r.get("roleId")]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting role IDs for UserMandate: {e}")
|
||||
return []
|
||||
|
||||
def addRoleToUserMandate(self, userMandateId: str, roleId: str) -> UserMandateRole:
|
||||
"""
|
||||
Add a role to a UserMandate.
|
||||
|
||||
Args:
|
||||
userMandateId: UserMandate ID
|
||||
roleId: Role ID to add
|
||||
|
||||
Returns:
|
||||
Created UserMandateRole object
|
||||
"""
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = self.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
|
||||
)
|
||||
if existing:
|
||||
cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")}
|
||||
return UserMandateRole(**cleanedRecord)
|
||||
|
||||
userMandateRole = UserMandateRole(
|
||||
userMandateId=userMandateId,
|
||||
roleId=roleId
|
||||
)
|
||||
createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return UserMandateRole(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding role to UserMandate: {e}")
|
||||
raise ValueError(f"Failed to add role: {e}")
|
||||
|
||||
def removeRoleFromUserMandate(self, userMandateId: str, roleId: str) -> bool:
|
||||
"""
|
||||
Remove a role from a UserMandate.
|
||||
If no roles remain, the UserMandate is deleted (Application-Level Cleanup).
|
||||
|
||||
Args:
|
||||
userMandateId: UserMandate ID
|
||||
roleId: Role ID to remove
|
||||
|
||||
Returns:
|
||||
True if removed
|
||||
"""
|
||||
try:
|
||||
# Find and delete the junction record
|
||||
records = self.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
|
||||
)
|
||||
if not records:
|
||||
return False
|
||||
|
||||
self.db.recordDelete(UserMandateRole, records[0].get("id"))
|
||||
|
||||
# Application-Level Cleanup: Delete UserMandate if no roles remain
|
||||
remainingRoles = self.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId}
|
||||
)
|
||||
if not remainingRoles:
|
||||
self.db.recordDelete(UserMandate, userMandateId)
|
||||
logger.info(f"Deleted empty UserMandate {userMandateId}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing role from UserMandate: {e}")
|
||||
raise ValueError(f"Failed to remove role: {e}")
|
||||
|
||||
# ============================================
|
||||
# Feature Access Methods (Multi-Tenant)
|
||||
# ============================================
|
||||
|
||||
def getFeatureAccess(self, userId: str, featureInstanceId: str) -> Optional[FeatureAccess]:
|
||||
"""
|
||||
Get FeatureAccess record for a user to a specific feature instance.
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
featureInstanceId: FeatureInstance ID
|
||||
|
||||
Returns:
|
||||
FeatureAccess object or None
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={"userId": userId, "featureInstanceId": featureInstanceId}
|
||||
)
|
||||
if not records:
|
||||
return None
|
||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||
return FeatureAccess(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting FeatureAccess: {e}")
|
||||
return None
|
||||
|
||||
def getFeatureAccessesForUser(self, userId: str) -> List[FeatureAccess]:
|
||||
"""
|
||||
Get all feature accesses for a user.
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
|
||||
Returns:
|
||||
List of FeatureAccess objects
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={"userId": userId, "enabled": True}
|
||||
)
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
result.append(FeatureAccess(**cleanedRecord))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting FeatureAccesses: {e}")
|
||||
return []
|
||||
|
||||
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
|
||||
"""
|
||||
Create a FeatureAccess record (grant user access to feature instance).
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
featureInstanceId: FeatureInstance ID
|
||||
roleIds: Optional list of role IDs to assign
|
||||
|
||||
Returns:
|
||||
Created FeatureAccess object
|
||||
"""
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = self.getFeatureAccess(userId, featureInstanceId)
|
||||
if existing:
|
||||
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
|
||||
|
||||
# Create FeatureAccess
|
||||
featureAccess = FeatureAccess(
|
||||
userId=userId,
|
||||
featureInstanceId=featureInstanceId,
|
||||
enabled=True
|
||||
)
|
||||
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||
|
||||
# Assign roles via junction table
|
||||
if roleIds and createdRecord:
|
||||
featureAccessId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
featureAccessRole = FeatureAccessRole(
|
||||
featureAccessId=featureAccessId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return FeatureAccess(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating FeatureAccess: {e}")
|
||||
raise ValueError(f"Failed to create FeatureAccess: {e}")
|
||||
|
||||
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
|
||||
"""
|
||||
Get all role IDs assigned to a FeatureAccess.
|
||||
|
||||
Args:
|
||||
featureAccessId: FeatureAccess ID
|
||||
|
||||
Returns:
|
||||
List of role IDs
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(
|
||||
FeatureAccessRole,
|
||||
recordFilter={"featureAccessId": featureAccessId}
|
||||
)
|
||||
return [r.get("roleId") for r in records if r.get("roleId")]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
|
||||
return []
|
||||
|
||||
# Token methods
|
||||
|
||||
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
|
||||
|
|
@ -1713,115 +2081,6 @@ class AppObjects:
|
|||
logger.error(f"Error during logout: {str(e)}")
|
||||
raise
|
||||
|
||||
# Neutralization methods
|
||||
|
||||
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
|
||||
"""Get the data neutralization configuration for the current user's mandate"""
|
||||
try:
|
||||
# Use RBAC filtering
|
||||
filtered_configs = getRecordsetWithRBAC(self.db,
|
||||
DataNeutraliserConfig,
|
||||
self.currentUser,
|
||||
recordFilter={"mandateId": self.mandateId}
|
||||
)
|
||||
|
||||
if not filtered_configs:
|
||||
return None
|
||||
|
||||
# Filter out database-specific fields
|
||||
configDict = filtered_configs[0]
|
||||
cleanedConfig = {k: v for k, v in configDict.items() if not k.startswith("_")}
|
||||
return DataNeutraliserConfig(**cleanedConfig)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neutralization config: {str(e)}")
|
||||
return None
|
||||
|
||||
def createOrUpdateNeutralizationConfig(
|
||||
self, config_data: Dict[str, Any]
|
||||
) -> DataNeutraliserConfig:
|
||||
"""Create or update the data neutralization configuration"""
|
||||
try:
|
||||
# Check if config already exists
|
||||
existing_config = self.getNeutralizationConfig()
|
||||
|
||||
if existing_config:
|
||||
# Update existing config
|
||||
update_data = existing_config.model_dump()
|
||||
update_data.update(config_data)
|
||||
update_data["updatedAt"] = getUtcTimestamp()
|
||||
|
||||
updated_config = DataNeutraliserConfig(**update_data)
|
||||
self.db.recordModify(
|
||||
DataNeutraliserConfig, existing_config.id, updated_config
|
||||
)
|
||||
|
||||
return updated_config
|
||||
else:
|
||||
# Create new config
|
||||
config_data["mandateId"] = self.mandateId
|
||||
config_data["userId"] = self.userId
|
||||
|
||||
new_config = DataNeutraliserConfig(**config_data)
|
||||
created_record = self.db.recordCreate(DataNeutraliserConfig, new_config)
|
||||
|
||||
return DataNeutraliserConfig(**created_record)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/updating neutralization config: {str(e)}")
|
||||
raise ValueError(f"Failed to create/update neutralization config: {str(e)}")
|
||||
|
||||
def getNeutralizationAttributes(
|
||||
self, file_id: Optional[str] = None
|
||||
) -> List[DataNeutralizerAttributes]:
|
||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||
try:
|
||||
filter_dict = {"mandateId": self.mandateId}
|
||||
if file_id:
|
||||
filter_dict["fileId"] = file_id
|
||||
|
||||
# Use RBAC filtering
|
||||
filtered_attributes = getRecordsetWithRBAC(self.db,
|
||||
DataNeutralizerAttributes,
|
||||
self.currentUser,
|
||||
recordFilter=filter_dict
|
||||
)
|
||||
|
||||
# Filter out database-specific fields
|
||||
cleaned_attributes = []
|
||||
for attr in filtered_attributes:
|
||||
cleanedAttr = {k: v for k, v in attr.items() if not k.startswith("_")}
|
||||
cleaned_attributes.append(cleanedAttr)
|
||||
|
||||
return [
|
||||
DataNeutralizerAttributes(**attr)
|
||||
for attr in cleaned_attributes
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
||||
return []
|
||||
|
||||
def deleteNeutralizationAttributes(self, file_id: str) -> bool:
|
||||
"""Delete all neutralization attributes for a specific file"""
|
||||
try:
|
||||
attributes = self.db.getRecordset(
|
||||
DataNeutralizerAttributes,
|
||||
recordFilter={"mandateId": self.mandateId, "fileId": file_id},
|
||||
)
|
||||
|
||||
for attribute in attributes:
|
||||
self.db.recordDelete(DataNeutralizerAttributes, attribute["id"])
|
||||
|
||||
logger.info(
|
||||
f"Deleted {len(attributes)} neutralization attributes for file {file_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting neutralization attributes: {str(e)}")
|
||||
return False
|
||||
|
||||
# RBAC CRUD Methods
|
||||
|
||||
def createAccessRule(self, accessRule: AccessRule) -> AccessRule:
|
||||
|
|
@ -1902,6 +2161,7 @@ class AppObjects:
|
|||
def getAccessRules(
|
||||
self,
|
||||
roleLabel: Optional[str] = None,
|
||||
roleId: Optional[str] = None,
|
||||
context: Optional[AccessRuleContext] = None,
|
||||
item: Optional[str] = None,
|
||||
pagination: Optional[PaginationParams] = None
|
||||
|
|
@ -1910,7 +2170,8 @@ class AppObjects:
|
|||
Get access rules with optional filters and pagination.
|
||||
|
||||
Args:
|
||||
roleLabel: Optional role label filter
|
||||
roleLabel: Optional role label filter (deprecated, use roleId)
|
||||
roleId: Optional role ID filter
|
||||
context: Optional context filter
|
||||
item: Optional item filter
|
||||
pagination: Optional pagination parameters. If None, returns all items.
|
||||
|
|
@ -1921,7 +2182,9 @@ class AppObjects:
|
|||
"""
|
||||
try:
|
||||
recordFilter = {}
|
||||
if roleLabel:
|
||||
if roleId:
|
||||
recordFilter["roleId"] = roleId
|
||||
elif roleLabel:
|
||||
recordFilter["roleLabel"] = roleLabel
|
||||
if context:
|
||||
recordFilter["context"] = context.value
|
||||
|
|
@ -2134,6 +2397,29 @@ class AppObjects:
|
|||
else:
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
|
||||
def countRoleAssignments(self) -> Dict[str, int]:
|
||||
"""
|
||||
Count the number of user assignments per role from UserMandateRole table.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping roleId to count of user assignments
|
||||
"""
|
||||
try:
|
||||
# Get all UserMandateRole records
|
||||
assignments = self.db.getRecordset(UserMandateRole)
|
||||
|
||||
# Count assignments per roleId
|
||||
roleCounts: Dict[str, int] = {}
|
||||
for assignment in assignments:
|
||||
roleId = str(assignment.get("roleId", ""))
|
||||
if roleId:
|
||||
roleCounts[roleId] = roleCounts.get(roleId, 0) + 1
|
||||
|
||||
return roleCounts
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting role assignments: {str(e)}")
|
||||
return {}
|
||||
|
||||
def updateRole(self, roleId: str, role: Role) -> Role:
|
||||
"""
|
||||
Update an existing role.
|
||||
|
|
@ -2185,14 +2471,13 @@ class AppObjects:
|
|||
if role.isSystemRole:
|
||||
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
|
||||
|
||||
# Check if role is assigned to any users
|
||||
allUsers = self.getUsersByMandate(None) # Get all users across all mandates
|
||||
for user in allUsers:
|
||||
if role.roleLabel in (user.roleLabels or []):
|
||||
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
|
||||
# Check if role is assigned to any users via UserMandateRole
|
||||
roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
|
||||
if roleAssignments:
|
||||
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
|
||||
|
||||
# Check if role is used in any access rules
|
||||
accessRules = self.getAccessRules(roleLabel=role.roleLabel)
|
||||
accessRules = self.getAccessRules(roleId=roleId)
|
||||
if accessRules:
|
||||
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
|
||||
|
||||
|
|
@ -2207,20 +2492,34 @@ class AppObjects:
|
|||
# Public Methods
|
||||
|
||||
|
||||
def getInterface(currentUser: User) -> AppObjects:
|
||||
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects:
|
||||
"""
|
||||
Returns a AppObjects instance for the current user.
|
||||
Handles initialization of database and records.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||
|
||||
Args:
|
||||
currentUser: User object
|
||||
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
|
||||
|
||||
Returns:
|
||||
AppObjects instance for the user context
|
||||
"""
|
||||
if not currentUser:
|
||||
raise ValueError("Invalid user context: user is required")
|
||||
|
||||
# Create context key
|
||||
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
|
||||
effectiveMandateId = mandateId
|
||||
|
||||
# Create context key (user + mandate combination)
|
||||
contextKey = f"{effectiveMandateId}_{currentUser.id}"
|
||||
|
||||
# Create new instance if not exists
|
||||
if contextKey not in _gatewayInterfaces:
|
||||
_gatewayInterfaces[contextKey] = AppObjects(currentUser)
|
||||
instance = AppObjects(currentUser)
|
||||
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||
_gatewayInterfaces[contextKey] = instance
|
||||
|
||||
return _gatewayInterfaces[contextKey]
|
||||
|
||||
1950
modules/interfaces/interfaceDbChat.py
Normal file
1950
modules/interfaces/interfaceDbChat.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -76,14 +76,23 @@ class ComponentObjects:
|
|||
# Initialize standard records if needed
|
||||
self._initRecords()
|
||||
|
||||
def setUserContext(self, currentUser: User):
|
||||
"""Sets the user context for the interface."""
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
|
||||
"""Sets the user context for the interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
|
||||
"""
|
||||
if not currentUser:
|
||||
logger.info("Initializing interface without user context")
|
||||
return
|
||||
|
||||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
self.featureInstanceId = featureInstanceId
|
||||
|
||||
if not self.userId:
|
||||
raise ValueError("Invalid user context: id is required")
|
||||
|
|
@ -116,11 +125,11 @@ class ComponentObjects:
|
|||
"""Initializes the database connection directly."""
|
||||
try:
|
||||
# Get configuration values with defaults
|
||||
dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data")
|
||||
dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management")
|
||||
dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT"))
|
||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||
dbDatabase = "poweron_management"
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
# Create database connector directly
|
||||
self.db = DatabaseConnector(
|
||||
|
|
@ -206,7 +215,7 @@ class ComponentObjects:
|
|||
|
||||
# Get the root interface to access the initial mandate ID
|
||||
from modules.security.rootAccess import getRootUser
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
rootUser = getRootUser()
|
||||
rootInterface = getInterface(rootUser)
|
||||
|
||||
|
|
@ -310,7 +319,9 @@ class ComponentObjects:
|
|||
permissions = self.rbac.getUserPermissions(
|
||||
self.currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
tableName
|
||||
tableName,
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId
|
||||
)
|
||||
|
||||
if operation == "create":
|
||||
|
|
@ -979,12 +990,15 @@ class ComponentObjects:
|
|||
fileSize = len(content)
|
||||
fileHash = hashlib.sha256(content).hexdigest()
|
||||
|
||||
# Ensure mandateId is valid
|
||||
mandateId = self.currentUser.mandateId or "default"
|
||||
# Use mandateId and featureInstanceId from context for proper data isolation
|
||||
# Convert None to empty string to satisfy Pydantic validation
|
||||
mandateId = self.mandateId or ""
|
||||
featureInstanceId = self.featureInstanceId or ""
|
||||
|
||||
# Create FileItem instance
|
||||
fileItem = FileItem(
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=featureInstanceId,
|
||||
fileName=uniqueName,
|
||||
mimeType=mimeType,
|
||||
fileSize=fileSize,
|
||||
|
|
@ -1320,9 +1334,11 @@ class ComponentObjects:
|
|||
if "userId" not in settingsData:
|
||||
settingsData["userId"] = self.userId
|
||||
|
||||
# Ensure mandateId is set
|
||||
# Ensure mandateId and featureInstanceId are set from context
|
||||
if "mandateId" not in settingsData:
|
||||
settingsData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default"
|
||||
settingsData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in settingsData:
|
||||
settingsData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
# Check if settings already exist for this user
|
||||
existingSettings = self.getVoiceSettings(settingsData["userId"])
|
||||
|
|
@ -1406,7 +1422,7 @@ class ComponentObjects:
|
|||
# Create default settings
|
||||
defaultSettings = {
|
||||
"userId": targetUserId,
|
||||
"mandateId": self.currentUser.mandateId if self.currentUser else "default",
|
||||
"mandateId": self.mandateId,
|
||||
"sttLanguage": "de-DE",
|
||||
"ttsLanguage": "de-DE",
|
||||
"ttsVoice": "de-DE-KatjaNeural",
|
||||
|
|
@ -1494,9 +1510,11 @@ class ComponentObjects:
|
|||
if not all(c.isalpha() or c == "_" for c in subscriptionId):
|
||||
raise ValueError("subscriptionId must contain only letters and underscores")
|
||||
|
||||
# Set mandateId if not provided
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in subscriptionData:
|
||||
subscriptionData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default"
|
||||
subscriptionData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in subscriptionData:
|
||||
subscriptionData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData)
|
||||
if not createdRecord or not createdRecord.get("id"):
|
||||
|
|
@ -1598,6 +1616,12 @@ class ComponentObjects:
|
|||
if "userId" not in registrationData:
|
||||
registrationData["userId"] = self.userId
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in registrationData:
|
||||
registrationData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in registrationData:
|
||||
registrationData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
createdRecord = self.db.recordCreate(MessagingSubscriptionRegistration, registrationData)
|
||||
if not createdRecord or not createdRecord.get("id"):
|
||||
raise ValueError("Failed to create registration record")
|
||||
|
|
@ -1672,6 +1696,13 @@ class ComponentObjects:
|
|||
def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]:
|
||||
"""Creates a new delivery record."""
|
||||
deliveryData = delivery.model_dump() if isinstance(delivery, MessagingDelivery) else delivery
|
||||
|
||||
# Set mandateId and featureInstanceId from context for proper data isolation
|
||||
if "mandateId" not in deliveryData or not deliveryData["mandateId"]:
|
||||
deliveryData["mandateId"] = self.mandateId
|
||||
if "featureInstanceId" not in deliveryData or not deliveryData["featureInstanceId"]:
|
||||
deliveryData["featureInstanceId"] = self.featureInstanceId
|
||||
|
||||
createdRecord = self.db.recordCreate(MessagingDelivery, deliveryData)
|
||||
if not createdRecord or not createdRecord.get("id"):
|
||||
raise ValueError("Failed to create delivery record")
|
||||
|
|
@ -1741,12 +1772,20 @@ class ComponentObjects:
|
|||
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
|
||||
|
||||
|
||||
def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
|
||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
|
||||
"""
|
||||
Returns a ComponentObjects instance.
|
||||
If currentUser is provided, initializes with user context.
|
||||
Otherwise, returns an instance with only database access.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
|
||||
"""
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||
|
||||
# Create new instance if not exists
|
||||
if "default" not in _instancesManagement:
|
||||
_instancesManagement["default"] = ComponentObjects()
|
||||
|
|
@ -1754,7 +1793,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
|
|||
interface = _instancesManagement["default"]
|
||||
|
||||
if currentUser:
|
||||
interface.setUserContext(currentUser)
|
||||
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||
else:
|
||||
logger.info("Returning interface without user context")
|
||||
|
||||
515
modules/interfaces/interfaceFeatures.py
Normal file
515
modules/interfaces/interfaceFeatures.py
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Feature Instance Management Interface.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Feature-Instanzen gehören zu Mandanten
|
||||
- Template-Rollen werden bei Erstellung kopiert
|
||||
- Synchronisation von Templates ist explizit (nicht automatisch)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||
from modules.datamodels.datamodelRbac import Role, AccessRule
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureInterface:
|
||||
"""
|
||||
Interface for Feature and FeatureInstance management.
|
||||
|
||||
Responsibilities:
|
||||
- CRUD operations for Features and FeatureInstances
|
||||
- Template role copying on instance creation
|
||||
- Template synchronization for existing instances
|
||||
"""
|
||||
|
||||
def __init__(self, db: DatabaseConnector):
|
||||
"""
|
||||
Initialize Feature interface.
|
||||
|
||||
Args:
|
||||
db: DatabaseConnector instance (DbApp database)
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# ============================================
|
||||
# Feature Methods (Global Feature Definitions)
|
||||
# ============================================
|
||||
|
||||
def getFeature(self, featureCode: str) -> Optional[Feature]:
|
||||
"""
|
||||
Get a feature by code.
|
||||
|
||||
Args:
|
||||
featureCode: Feature code (e.g., "trustee", "chatbot")
|
||||
|
||||
Returns:
|
||||
Feature object or None
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
|
||||
if not records:
|
||||
return None
|
||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||
return Feature(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting feature {featureCode}: {e}")
|
||||
return None
|
||||
|
||||
def getAllFeatures(self) -> List[Feature]:
|
||||
"""
|
||||
Get all available features.
|
||||
|
||||
Returns:
|
||||
List of Feature objects
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(Feature)
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
result.append(Feature(**cleanedRecord))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all features: {e}")
|
||||
return []
|
||||
|
||||
def createFeature(self, code: str, label: Dict[str, str], icon: str = "mdi-puzzle") -> Feature:
|
||||
"""
|
||||
Create a new feature definition.
|
||||
|
||||
Args:
|
||||
code: Unique feature code (e.g., "trustee")
|
||||
label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"})
|
||||
icon: Icon identifier
|
||||
|
||||
Returns:
|
||||
Created Feature object
|
||||
"""
|
||||
try:
|
||||
feature = Feature(code=code, label=label, icon=icon)
|
||||
createdRecord = self.db.recordCreate(Feature, feature.model_dump())
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return Feature(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating feature {code}: {e}")
|
||||
raise ValueError(f"Failed to create feature: {e}")
|
||||
|
||||
# ============================================
|
||||
# Feature Instance Methods
|
||||
# ============================================
|
||||
|
||||
def getFeatureInstance(self, instanceId: str) -> Optional[FeatureInstance]:
|
||||
"""
|
||||
Get a feature instance by ID.
|
||||
|
||||
Args:
|
||||
instanceId: FeatureInstance ID
|
||||
|
||||
Returns:
|
||||
FeatureInstance object or None
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
|
||||
if not records:
|
||||
return None
|
||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||
return FeatureInstance(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting feature instance {instanceId}: {e}")
|
||||
return None
|
||||
|
||||
def getFeatureInstancesForMandate(self, mandateId: str, featureCode: Optional[str] = None) -> List[FeatureInstance]:
|
||||
"""
|
||||
Get all feature instances for a mandate.
|
||||
|
||||
Args:
|
||||
mandateId: Mandate ID
|
||||
featureCode: Optional filter by feature code
|
||||
|
||||
Returns:
|
||||
List of FeatureInstance objects
|
||||
"""
|
||||
try:
|
||||
recordFilter = {"mandateId": mandateId}
|
||||
if featureCode:
|
||||
recordFilter["featureCode"] = featureCode
|
||||
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
result.append(FeatureInstance(**cleanedRecord))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting feature instances for mandate {mandateId}: {e}")
|
||||
return []
|
||||
|
||||
def createFeatureInstance(
|
||||
self,
|
||||
featureCode: str,
|
||||
mandateId: str,
|
||||
label: str,
|
||||
enabled: bool = True,
|
||||
copyTemplateRoles: bool = True
|
||||
) -> FeatureInstance:
|
||||
"""
|
||||
Create a new feature instance for a mandate.
|
||||
|
||||
Optionally copies global template roles for this feature.
|
||||
|
||||
WICHTIG: Templates werden NUR bei Erstellung kopiert.
|
||||
Spätere Template-Änderungen werden NICHT automatisch propagiert.
|
||||
Für manuelle Nachsynchronisation siehe syncRolesFromTemplate().
|
||||
|
||||
Args:
|
||||
featureCode: Feature code (e.g., "trustee")
|
||||
mandateId: Mandate ID
|
||||
label: Instance label (e.g., "Buchhaltung 2025")
|
||||
enabled: Whether the instance is enabled
|
||||
copyTemplateRoles: Whether to copy template roles
|
||||
|
||||
Returns:
|
||||
Created FeatureInstance object
|
||||
"""
|
||||
try:
|
||||
# Create instance
|
||||
instance = FeatureInstance(
|
||||
featureCode=featureCode,
|
||||
mandateId=mandateId,
|
||||
label=label,
|
||||
enabled=enabled
|
||||
)
|
||||
createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump())
|
||||
|
||||
if not createdInstance:
|
||||
raise ValueError("Failed to create feature instance record")
|
||||
|
||||
instanceId = createdInstance.get("id")
|
||||
|
||||
# Copy template roles if requested
|
||||
if copyTemplateRoles:
|
||||
self._copyTemplateRoles(featureCode, mandateId, instanceId)
|
||||
|
||||
cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")}
|
||||
return FeatureInstance(**cleanedRecord)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating feature instance: {e}")
|
||||
raise ValueError(f"Failed to create feature instance: {e}")
|
||||
|
||||
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
||||
"""
|
||||
Copy global template roles for a feature to a new instance.
|
||||
|
||||
Args:
|
||||
featureCode: Feature code
|
||||
mandateId: Mandate ID
|
||||
instanceId: New FeatureInstance ID
|
||||
|
||||
Returns:
|
||||
Number of roles copied
|
||||
"""
|
||||
try:
|
||||
# Find global template roles for this feature (mandateId=None)
|
||||
globalRoles = self.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"featureCode": featureCode, "mandateId": None}
|
||||
)
|
||||
|
||||
if not globalRoles:
|
||||
logger.debug(f"No template roles found for feature {featureCode}")
|
||||
return 0
|
||||
|
||||
templateRoleIds = [r.get("id") for r in globalRoles]
|
||||
|
||||
# BULK: Load all template AccessRules in one query
|
||||
allTemplateRules = []
|
||||
for roleId in templateRoleIds:
|
||||
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
||||
allTemplateRules.extend([(roleId, r) for r in rules])
|
||||
|
||||
# Index for fast lookup: roleId -> rules
|
||||
rulesByRoleId = {}
|
||||
for roleId, rule in allTemplateRules:
|
||||
if roleId not in rulesByRoleId:
|
||||
rulesByRoleId[roleId] = []
|
||||
rulesByRoleId[roleId].append(rule)
|
||||
|
||||
# Copy roles and their AccessRules
|
||||
copiedCount = 0
|
||||
for templateRole in globalRoles:
|
||||
newRoleId = str(uuid.uuid4())
|
||||
|
||||
# Create new role for this instance
|
||||
newRole = Role(
|
||||
id=newRoleId,
|
||||
roleLabel=templateRole.get("roleLabel"),
|
||||
description=templateRole.get("description", {}),
|
||||
featureCode=featureCode,
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=instanceId,
|
||||
isSystemRole=False
|
||||
)
|
||||
self.db.recordCreate(Role, newRole.model_dump())
|
||||
|
||||
# Copy AccessRules for this role
|
||||
templateRulesForRole = rulesByRoleId.get(templateRole.get("id"), [])
|
||||
for rule in templateRulesForRole:
|
||||
newRule = AccessRule(
|
||||
id=str(uuid.uuid4()),
|
||||
roleId=newRoleId,
|
||||
context=rule.get("context"),
|
||||
item=rule.get("item"),
|
||||
view=rule.get("view", False),
|
||||
read=rule.get("read"),
|
||||
create=rule.get("create"),
|
||||
update=rule.get("update"),
|
||||
delete=rule.get("delete")
|
||||
)
|
||||
self.db.recordCreate(AccessRule, newRule.model_dump())
|
||||
|
||||
copiedCount += 1
|
||||
|
||||
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
|
||||
return copiedCount
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error copying template roles: {e}")
|
||||
return 0
|
||||
|
||||
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
|
||||
"""
|
||||
Synchronize roles of a feature instance with current templates.
|
||||
|
||||
WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert.
|
||||
Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für
|
||||
automatische Propagation von Template-Änderungen.
|
||||
|
||||
Args:
|
||||
featureInstanceId: ID of the instance to sync
|
||||
addOnly: If True, only add missing roles. If False, also remove extras.
|
||||
|
||||
Returns:
|
||||
Dict with added/removed/unchanged counts
|
||||
"""
|
||||
try:
|
||||
instance = self.getFeatureInstance(featureInstanceId)
|
||||
if not instance:
|
||||
raise ValueError(f"FeatureInstance {featureInstanceId} not found")
|
||||
|
||||
featureCode = instance.featureCode
|
||||
mandateId = instance.mandateId
|
||||
|
||||
# Get current template roles
|
||||
templateRoles = self.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"featureCode": featureCode, "mandateId": None}
|
||||
)
|
||||
templateLabels = {r.get("roleLabel") for r in templateRoles}
|
||||
|
||||
# Get current instance roles
|
||||
instanceRoles = self.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"featureInstanceId": featureInstanceId}
|
||||
)
|
||||
instanceLabels = {r.get("roleLabel") for r in instanceRoles}
|
||||
|
||||
result = {"added": 0, "removed": 0, "unchanged": 0}
|
||||
|
||||
# Add missing roles
|
||||
for templateRole in templateRoles:
|
||||
if templateRole.get("roleLabel") not in instanceLabels:
|
||||
# Copy this role
|
||||
newRoleId = str(uuid.uuid4())
|
||||
newRole = Role(
|
||||
id=newRoleId,
|
||||
roleLabel=templateRole.get("roleLabel"),
|
||||
description=templateRole.get("description", {}),
|
||||
featureCode=featureCode,
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=featureInstanceId,
|
||||
isSystemRole=False
|
||||
)
|
||||
self.db.recordCreate(Role, newRole.model_dump())
|
||||
|
||||
# Copy AccessRules
|
||||
templateRules = self.db.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": templateRole.get("id")}
|
||||
)
|
||||
for rule in templateRules:
|
||||
newRule = AccessRule(
|
||||
id=str(uuid.uuid4()),
|
||||
roleId=newRoleId,
|
||||
context=rule.get("context"),
|
||||
item=rule.get("item"),
|
||||
view=rule.get("view", False),
|
||||
read=rule.get("read"),
|
||||
create=rule.get("create"),
|
||||
update=rule.get("update"),
|
||||
delete=rule.get("delete")
|
||||
)
|
||||
self.db.recordCreate(AccessRule, newRule.model_dump())
|
||||
|
||||
result["added"] += 1
|
||||
else:
|
||||
result["unchanged"] += 1
|
||||
|
||||
# Remove extra roles (optional)
|
||||
if not addOnly:
|
||||
from modules.datamodels.datamodelMembership import FeatureAccessRole
|
||||
|
||||
for instanceRole in instanceRoles:
|
||||
if instanceRole.get("roleLabel") not in templateLabels:
|
||||
# Check if role is still in use
|
||||
usages = self.db.getRecordset(
|
||||
FeatureAccessRole,
|
||||
recordFilter={"roleId": instanceRole.get("id")}
|
||||
)
|
||||
if not usages:
|
||||
self.db.recordDelete(Role, instanceRole.get("id"))
|
||||
result["removed"] += 1
|
||||
|
||||
logger.info(f"Synced roles for instance {featureInstanceId}: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing roles from template: {e}")
|
||||
raise ValueError(f"Failed to sync roles: {e}")
|
||||
|
||||
def updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]:
|
||||
"""
|
||||
Update a feature instance.
|
||||
|
||||
Only label and enabled fields can be updated.
|
||||
featureCode and mandateId are immutable.
|
||||
|
||||
Args:
|
||||
instanceId: FeatureInstance ID
|
||||
updateData: Dictionary with fields to update (label, enabled)
|
||||
|
||||
Returns:
|
||||
Updated FeatureInstance object or None if not found
|
||||
"""
|
||||
try:
|
||||
instance = self.getFeatureInstance(instanceId)
|
||||
if not instance:
|
||||
return None
|
||||
|
||||
# Only allow updating specific fields
|
||||
allowedFields = {"label", "enabled"}
|
||||
filteredData = {k: v for k, v in updateData.items() if k in allowedFields}
|
||||
|
||||
if not filteredData:
|
||||
return instance
|
||||
|
||||
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
|
||||
if updated:
|
||||
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
|
||||
return FeatureInstance(**cleanedRecord)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating feature instance {instanceId}: {e}")
|
||||
raise ValueError(f"Failed to update feature instance: {e}")
|
||||
|
||||
def deleteFeatureInstance(self, instanceId: str) -> bool:
|
||||
"""
|
||||
Delete a feature instance.
|
||||
CASCADE will delete associated roles and access records.
|
||||
|
||||
Args:
|
||||
instanceId: FeatureInstance ID
|
||||
|
||||
Returns:
|
||||
True if deleted
|
||||
"""
|
||||
try:
|
||||
instance = self.getFeatureInstance(instanceId)
|
||||
if not instance:
|
||||
return False
|
||||
|
||||
return self.db.recordDelete(FeatureInstance, instanceId)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting feature instance {instanceId}: {e}")
|
||||
raise ValueError(f"Failed to delete feature instance: {e}")
|
||||
|
||||
# ============================================
|
||||
# Template Role Methods (Global)
|
||||
# ============================================
|
||||
|
||||
def getTemplateRoles(self, featureCode: Optional[str] = None) -> List[Role]:
|
||||
"""
|
||||
Get global template roles (mandateId=None).
|
||||
|
||||
Args:
|
||||
featureCode: Optional filter by feature code
|
||||
|
||||
Returns:
|
||||
List of Role objects
|
||||
"""
|
||||
try:
|
||||
recordFilter = {"mandateId": None}
|
||||
if featureCode:
|
||||
recordFilter["featureCode"] = featureCode
|
||||
records = self.db.getRecordset(Role, recordFilter=recordFilter)
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
result.append(Role(**cleanedRecord))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting template roles: {e}")
|
||||
return []
|
||||
|
||||
def createTemplateRole(
|
||||
self,
|
||||
roleLabel: str,
|
||||
featureCode: str,
|
||||
description: Dict[str, str] = None
|
||||
) -> Role:
|
||||
"""
|
||||
Create a global template role for a feature.
|
||||
|
||||
Args:
|
||||
roleLabel: Role label (e.g., "admin", "viewer")
|
||||
featureCode: Feature code this role belongs to
|
||||
description: I18n descriptions
|
||||
|
||||
Returns:
|
||||
Created Role object
|
||||
"""
|
||||
try:
|
||||
role = Role(
|
||||
roleLabel=roleLabel,
|
||||
description=description or {},
|
||||
featureCode=featureCode,
|
||||
mandateId=None, # Global template
|
||||
featureInstanceId=None,
|
||||
isSystemRole=False
|
||||
)
|
||||
createdRecord = self.db.recordCreate(Role, role.model_dump())
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return Role(**cleanedRecord)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating template role: {e}")
|
||||
raise ValueError(f"Failed to create template role: {e}")
|
||||
|
||||
|
||||
def getFeatureInterface(db: DatabaseConnector) -> FeatureInterface:
|
||||
"""
|
||||
Factory function to get a FeatureInterface instance.
|
||||
|
||||
Args:
|
||||
db: DatabaseConnector instance (DbApp database)
|
||||
|
||||
Returns:
|
||||
FeatureInterface instance
|
||||
"""
|
||||
return FeatureInterface(db)
|
||||
|
|
@ -3,6 +3,22 @@
|
|||
"""
|
||||
RBAC helper functions for interfaces.
|
||||
Provides RBAC filtering for database queries without connectors importing security.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
|
||||
- GROUP-Filter verwendet expliziten mandateId Parameter
|
||||
|
||||
Data Namespace Structure:
|
||||
- data.uam.{Table} → User Access Management (mandantenübergreifend)
|
||||
- data.chat.{Table} → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
|
||||
- data.files.{Table} → Dateien (benutzer-eigen)
|
||||
- data.automation.{Table} → Automation (benutzer-eigen)
|
||||
- data.feature.{code}.{Table} → Mandanten-/Feature-spezifische Daten (dynamisch)
|
||||
|
||||
GROUP-Berechtigung:
|
||||
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
|
||||
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
|
||||
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -17,6 +33,72 @@ from modules.security.rootAccess import getRootDbAppConnector
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Namespace-Mapping für statische Tabellen
|
||||
# =============================================================================
|
||||
# Definiert, welcher Namespace für jede Tabelle verwendet wird.
|
||||
# Tabellen ohne Eintrag fallen auf "system" zurück (Fallback für Rückwärtskompatibilität).
|
||||
# =============================================================================
|
||||
|
||||
TABLE_NAMESPACE = {
|
||||
# UAM (User Access Management) - mandantenübergreifend
|
||||
"UserInDB": "uam",
|
||||
"UserConnection": "uam",
|
||||
"AuthEvent": "uam",
|
||||
"Mandate": "uam",
|
||||
"UserMandate": "uam",
|
||||
"UserMandateRole": "uam",
|
||||
"Invitation": "uam",
|
||||
"Role": "uam",
|
||||
"AccessRule": "uam",
|
||||
"FeatureInstance": "uam",
|
||||
"FeatureAccess": "uam",
|
||||
"FeatureAccessRole": "uam",
|
||||
# Chat - benutzer-eigen, kein Mandantenkontext
|
||||
"ChatWorkflow": "chat",
|
||||
"ChatMessage": "chat",
|
||||
"ChatLog": "chat",
|
||||
"ChatStat": "chat",
|
||||
"ChatDocument": "chat",
|
||||
"Prompt": "chat",
|
||||
# Files - benutzer-eigen
|
||||
"FileItem": "files",
|
||||
"FileData": "files",
|
||||
# Automation - benutzer-eigen
|
||||
"AutomationDefinition": "automation",
|
||||
}
|
||||
|
||||
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
|
||||
USER_OWNED_NAMESPACES = {"chat", "files", "automation"}
|
||||
|
||||
|
||||
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build the standardized objectKey for a DATA context item.
|
||||
|
||||
Format:
|
||||
- UAM tables: data.uam.{TableName}
|
||||
- Chat tables: data.chat.{TableName}
|
||||
- File tables: data.files.{TableName}
|
||||
- Automation tables: data.automation.{TableName}
|
||||
- Feature tables: data.feature.{featureCode}.{TableName}
|
||||
|
||||
Args:
|
||||
tableName: The database table name (e.g., "UserInDB", "ChatWorkflow")
|
||||
featureCode: Optional feature code (e.g., "trustee", "realestate")
|
||||
If provided, uses data.feature.{featureCode}.{tableName}
|
||||
|
||||
Returns:
|
||||
Full objectKey string (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow",
|
||||
or "data.feature.trustee.TrusteePosition")
|
||||
"""
|
||||
if featureCode:
|
||||
return f"data.feature.{featureCode}.{tableName}"
|
||||
|
||||
namespace = TABLE_NAMESPACE.get(tableName, "system") # Fallback für unbekannte Tabellen
|
||||
return f"data.{namespace}.{tableName}"
|
||||
|
||||
|
||||
def getRecordsetWithRBAC(
|
||||
connector, # DatabaseConnector instance
|
||||
modelClass: Type[BaseModel],
|
||||
|
|
@ -24,41 +106,72 @@ def getRecordsetWithRBAC(
|
|||
recordFilter: Dict[str, Any] = None,
|
||||
orderBy: str = None,
|
||||
limit: int = None,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None,
|
||||
enrichPermissions: bool = False,
|
||||
featureCode: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get records with RBAC filtering applied at database level.
|
||||
This function wraps connector.getRecordset() with RBAC logic.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||
|
||||
Args:
|
||||
connector: DatabaseConnector instance
|
||||
modelClass: Pydantic model class for the table
|
||||
currentUser: User object with roleLabels
|
||||
currentUser: User object
|
||||
recordFilter: Additional record filters
|
||||
orderBy: Field to order by (defaults to "id")
|
||||
limit: Maximum number of records to return
|
||||
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||
featureInstanceId: Explicit feature instance context
|
||||
enrichPermissions: If True, adds _permissions field to each record with row-level
|
||||
permissions { canUpdate, canDelete } based on RBAC rules and _createdBy
|
||||
featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
|
||||
If None, table is treated as a system table.
|
||||
|
||||
Returns:
|
||||
List of filtered records
|
||||
List of filtered records (with _permissions if enrichPermissions=True)
|
||||
"""
|
||||
table = modelClass.__name__
|
||||
# Build full objectKey for RBAC lookup
|
||||
objectKey = buildDataObjectKey(table, featureCode)
|
||||
|
||||
effectiveMandateId = mandateId
|
||||
|
||||
try:
|
||||
if not connector._ensureTableExists(modelClass):
|
||||
return []
|
||||
|
||||
# Get RBAC permissions for this table
|
||||
# SysAdmin bypass: SysAdmin users have full access to all tables
|
||||
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
||||
if isSysAdmin:
|
||||
# Direct access without RBAC filtering
|
||||
# Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path
|
||||
records = connector.getRecordset(modelClass, recordFilter=recordFilter)
|
||||
if enrichPermissions:
|
||||
# SysAdmin has full permissions on all records
|
||||
for record in records:
|
||||
record["_permissions"] = {"canUpdate": True, "canDelete": True}
|
||||
return records
|
||||
|
||||
# Get RBAC permissions for this table using full objectKey
|
||||
# AccessRule table is always in DbApp database
|
||||
dbApp = getRootDbAppConnector()
|
||||
rbacInstance = RbacClass(connector, dbApp=dbApp)
|
||||
permissions = rbacInstance.getUserPermissions(
|
||||
currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
table
|
||||
objectKey, # Use full objectKey (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow")
|
||||
mandateId=effectiveMandateId,
|
||||
featureInstanceId=featureInstanceId
|
||||
)
|
||||
|
||||
# Check view permission first
|
||||
if not permissions.view:
|
||||
logger.debug(f"User {currentUser.id} has no view permission for table {table}")
|
||||
logger.debug(f"User {currentUser.id} has no view permission for {objectKey}")
|
||||
return []
|
||||
|
||||
# Build WHERE clause with RBAC filtering
|
||||
|
|
@ -66,7 +179,13 @@ def getRecordsetWithRBAC(
|
|||
whereValues = []
|
||||
|
||||
# Add RBAC WHERE clause based on read permission
|
||||
rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector)
|
||||
rbacWhereClause = buildRbacWhereClause(
|
||||
permissions,
|
||||
currentUser,
|
||||
table,
|
||||
connector,
|
||||
mandateId=effectiveMandateId
|
||||
)
|
||||
if rbacWhereClause:
|
||||
whereConditions.append(rbacWhereClause["condition"])
|
||||
whereValues.extend(rbacWhereClause["values"])
|
||||
|
|
@ -145,6 +264,12 @@ def getRecordsetWithRBAC(
|
|||
f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}"
|
||||
)
|
||||
|
||||
# Enrich records with row-level permissions if requested
|
||||
if enrichPermissions:
|
||||
records = _enrichRecordsWithPermissions(
|
||||
records, permissions, currentUser
|
||||
)
|
||||
|
||||
return records
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading records with RBAC from table {table}: {e}")
|
||||
|
|
@ -155,17 +280,21 @@ def buildRbacWhereClause(
|
|||
permissions: UserPermissions,
|
||||
currentUser: User,
|
||||
table: str,
|
||||
connector # DatabaseConnector instance for connection access
|
||||
connector, # DatabaseConnector instance for connection access
|
||||
mandateId: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Build RBAC WHERE clause based on permissions and access level.
|
||||
Moved from connector to interfaces.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||
|
||||
Args:
|
||||
permissions: UserPermissions object
|
||||
currentUser: User object
|
||||
table: Table name
|
||||
connector: DatabaseConnector instance (needed for GROUP queries)
|
||||
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||
|
||||
Returns:
|
||||
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
||||
|
|
@ -199,29 +328,70 @@ def buildRbacWhereClause(
|
|||
"values": [currentUser.id]
|
||||
}
|
||||
|
||||
# Group records - filter by mandateId
|
||||
# Group records - filter by mandateId or ownership based on namespace
|
||||
if readLevel == AccessLevel.GROUP:
|
||||
if not currentUser.mandateId:
|
||||
# Determine namespace for this table
|
||||
namespace = TABLE_NAMESPACE.get(table, "system")
|
||||
|
||||
# For user-owned namespaces (chat, files, automation):
|
||||
# GROUP has no meaning - these tables have no mandate context
|
||||
# Simply ignore GROUP (no filtering)
|
||||
if namespace in USER_OWNED_NAMESPACES:
|
||||
return None
|
||||
|
||||
# For UAM and other namespaces: GROUP filters by mandate
|
||||
effectiveMandateId = mandateId
|
||||
|
||||
if not effectiveMandateId:
|
||||
# Fall back to Root mandate (first mandate in system) for GROUP access
|
||||
# This allows system-level tables to be accessed without explicit mandate context
|
||||
try:
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
dbApp = getRootDbAppConnector()
|
||||
allMandates = dbApp.getRecordset(Mandate)
|
||||
if allMandates:
|
||||
effectiveMandateId = allMandates[0].get("id")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Root mandate: {e}")
|
||||
|
||||
if not effectiveMandateId:
|
||||
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
|
||||
# For UserInDB, filter by mandateId directly
|
||||
# For UserInDB: Filter via UserMandate junction table
|
||||
# Multi-Tenant Design: Users do NOT have mandateId - they are linked via UserMandate
|
||||
if table == "UserInDB":
|
||||
return {
|
||||
"condition": '"mandateId" = %s',
|
||||
"values": [currentUser.mandateId]
|
||||
}
|
||||
# For UserConnection, need to join with UserInDB or filter by mandateId in user
|
||||
elif table == "UserConnection":
|
||||
# Get all user IDs in the same mandate using direct SQL query
|
||||
try:
|
||||
with connector.connection.cursor() as cursor:
|
||||
# Get all user IDs that are members of the current mandate
|
||||
cursor.execute(
|
||||
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
|
||||
(currentUser.mandateId,)
|
||||
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
|
||||
(effectiveMandateId,)
|
||||
)
|
||||
users = cursor.fetchall()
|
||||
userIds = [u["id"] for u in users]
|
||||
userMandates = cursor.fetchall()
|
||||
userIds = [um["userId"] for um in userMandates]
|
||||
if not userIds:
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
placeholders = ",".join(["%s"] * len(userIds))
|
||||
return {
|
||||
"condition": f'"id" IN ({placeholders})',
|
||||
"values": userIds
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
|
||||
# For UserConnection: Filter via UserMandate junction table
|
||||
elif table == "UserConnection":
|
||||
try:
|
||||
with connector.connection.cursor() as cursor:
|
||||
# Get all user IDs that are members of the current mandate
|
||||
cursor.execute(
|
||||
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
|
||||
(effectiveMandateId,)
|
||||
)
|
||||
userMandates = cursor.fetchall()
|
||||
userIds = [um["userId"] for um in userMandates]
|
||||
if not userIds:
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
placeholders = ",".join(["%s"] * len(userIds))
|
||||
|
|
@ -232,12 +402,102 @@ def buildRbacWhereClause(
|
|||
except Exception as e:
|
||||
logger.error(f"Error building GROUP filter for UserConnection: {e}")
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
# For other tables, filter by mandateId
|
||||
|
||||
# For system tables without mandateId column (Mandate, Role, etc.):
|
||||
# No row-level filtering - GROUP access = ALL access for these
|
||||
elif table in ("Mandate", "Role"):
|
||||
return None
|
||||
|
||||
# For other tables, filter by mandateId field
|
||||
# Also include records with NULL mandateId for backwards compatibility
|
||||
else:
|
||||
return {
|
||||
"condition": '"mandateId" = %s',
|
||||
"values": [currentUser.mandateId]
|
||||
"condition": '("mandateId" = %s OR "mandateId" IS NULL)',
|
||||
"values": [effectiveMandateId]
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _enrichRecordsWithPermissions(
|
||||
records: List[Dict[str, Any]],
|
||||
permissions: UserPermissions,
|
||||
currentUser: User
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enrich records with per-row permissions (_permissions field).
|
||||
|
||||
The _permissions field contains:
|
||||
- canUpdate: bool - whether current user can update this record
|
||||
- canDelete: bool - whether current user can delete this record
|
||||
|
||||
Logic:
|
||||
- AccessLevel.ALL ('a'): User can update/delete all records
|
||||
- AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId
|
||||
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
|
||||
- AccessLevel.NONE ('n'): User cannot update/delete any records
|
||||
|
||||
Args:
|
||||
records: List of record dicts
|
||||
permissions: UserPermissions with update/delete levels
|
||||
currentUser: Current user object
|
||||
|
||||
Returns:
|
||||
Records with _permissions field added
|
||||
"""
|
||||
enriched = []
|
||||
userId = currentUser.id if currentUser else None
|
||||
|
||||
for record in records:
|
||||
recordCopy = dict(record)
|
||||
createdBy = record.get("_createdBy")
|
||||
|
||||
# Determine canUpdate
|
||||
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
|
||||
# Determine canDelete
|
||||
canDelete = _checkRowPermission(permissions.delete, userId, createdBy)
|
||||
|
||||
recordCopy["_permissions"] = {
|
||||
"canUpdate": canUpdate,
|
||||
"canDelete": canDelete
|
||||
}
|
||||
enriched.append(recordCopy)
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def _checkRowPermission(
|
||||
accessLevel: Optional[AccessLevel],
|
||||
userId: Optional[str],
|
||||
recordCreatedBy: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has permission for a specific row based on access level.
|
||||
|
||||
Args:
|
||||
accessLevel: The permission level (ALL, MY, GROUP, NONE)
|
||||
userId: Current user's ID
|
||||
recordCreatedBy: The _createdBy value of the record
|
||||
|
||||
Returns:
|
||||
True if user has permission, False otherwise
|
||||
"""
|
||||
if not accessLevel or accessLevel == AccessLevel.NONE:
|
||||
return False
|
||||
|
||||
if accessLevel == AccessLevel.ALL:
|
||||
return True
|
||||
|
||||
# MY and GROUP: Check ownership via _createdBy
|
||||
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
|
||||
# If record has no _createdBy, allow access (can't verify ownership)
|
||||
if not recordCreatedBy:
|
||||
return True
|
||||
# If no userId, can't verify - deny
|
||||
if not userId:
|
||||
return False
|
||||
# Check ownership
|
||||
return recordCreatedBy == userId
|
||||
|
||||
# Unknown level - deny by default
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -31,19 +31,26 @@ class VoiceObjects:
|
|||
self.userId: Optional[str] = None
|
||||
self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None
|
||||
|
||||
def setUserContext(self, currentUser: User):
|
||||
"""Set the user context for the interface."""
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||
"""Set the user context for the interface.
|
||||
|
||||
Args:
|
||||
currentUser: The authenticated user
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||
"""
|
||||
if not currentUser:
|
||||
logger.info("Initializing voice interface without user context")
|
||||
return
|
||||
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id
|
||||
# Use mandateId from parameter (Request-Context), not from user object
|
||||
self.mandateId = mandateId
|
||||
|
||||
if not self.userId:
|
||||
raise ValueError("Invalid user context: id is required")
|
||||
|
||||
logger.debug(f"Voice interface user context set: userId={self.userId}")
|
||||
logger.debug(f"Voice interface user context set: userId={self.userId}, mandateId={self.mandateId}")
|
||||
|
||||
def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech:
|
||||
"""Get or create Google Cloud Speech connector instance."""
|
||||
|
|
@ -308,11 +315,11 @@ class VoiceObjects:
|
|||
try:
|
||||
logger.info(f"Creating voice settings: {settingsData}")
|
||||
|
||||
# Ensure mandateId is set from user context if not provided
|
||||
# Ensure mandateId is set from context if not provided
|
||||
if "mandateId" not in settingsData or not settingsData["mandateId"]:
|
||||
if not self.currentUser or not self.currentUser.mandateId:
|
||||
raise ValueError("mandateId is required but not provided and user context has no mandateId")
|
||||
settingsData["mandateId"] = self.currentUser.mandateId
|
||||
if not self.mandateId:
|
||||
raise ValueError("mandateId is required but not provided and context has no mandateId")
|
||||
settingsData["mandateId"] = self.mandateId
|
||||
|
||||
# Add timestamps
|
||||
currentTime = getUtcTimestamp()
|
||||
|
|
@ -376,7 +383,7 @@ class VoiceObjects:
|
|||
# Create default settings if none exist
|
||||
defaultSettings = {
|
||||
"userId": userId,
|
||||
"mandateId": self.currentUser.mandateId,
|
||||
"mandateId": self.mandateId,
|
||||
"sttLanguage": "de-DE",
|
||||
"ttsLanguage": "de-DE",
|
||||
"ttsVoice": "de-DE-Wavenet-A",
|
||||
|
|
@ -524,21 +531,22 @@ class VoiceObjects:
|
|||
}
|
||||
|
||||
|
||||
def getVoiceInterface(currentUser: User = None) -> VoiceObjects:
|
||||
def getVoiceInterface(currentUser: User = None, mandateId: Optional[str] = None) -> VoiceObjects:
|
||||
"""
|
||||
Factory function to get or create Voice interface instance.
|
||||
|
||||
Args:
|
||||
currentUser: User object for context (optional)
|
||||
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||
|
||||
Returns:
|
||||
VoiceObjects instance
|
||||
"""
|
||||
# For now, create a new instance each time
|
||||
# In the future, this could be enhanced with singleton pattern per user
|
||||
effectiveMandateId = str(mandateId) if mandateId else None
|
||||
|
||||
voiceInterface = VoiceObjects()
|
||||
|
||||
if currentUser:
|
||||
voiceInterface.setUserContext(currentUser)
|
||||
voiceInterface.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||
|
||||
return voiceInterface
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from fastapi import HTTPException, status
|
|||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
||||
# Static folder setup - using absolute path from app root
|
||||
baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway root
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ from typing import List, Dict, Any
|
|||
from fastapi import status
|
||||
import logging
|
||||
|
||||
# Import interfaces and models
|
||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
# Import interfaces and models from feature containers
|
||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
||||
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -31,26 +31,16 @@ router = APIRouter(
|
|||
}
|
||||
)
|
||||
|
||||
def requireSysadmin(currentUser: User):
|
||||
"""Require sysadmin role"""
|
||||
if "sysadmin" not in (currentUser.roleLabels or []):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Sysadmin role required"
|
||||
)
|
||||
|
||||
@router.get("")
|
||||
@limiter.limit("30/minute")
|
||||
async def get_all_automation_events(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all automation events across all mandates (sysadmin only).
|
||||
Returns list of all registered events with their automation IDs and schedules.
|
||||
"""
|
||||
requireSysadmin(currentUser)
|
||||
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
|
||||
|
|
@ -79,18 +69,15 @@ async def get_all_automation_events(
|
|||
@limiter.limit("5/minute")
|
||||
async def sync_all_automation_events(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually trigger sync for all automations (sysadmin only).
|
||||
This will register/remove events based on active flags.
|
||||
"""
|
||||
requireSysadmin(currentUser)
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||
from modules.features.workflow import syncAutomationEvents
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.workflows.automation import syncAutomationEvents
|
||||
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
# Get event user for sync operation (routes can import from interfaces)
|
||||
|
|
@ -124,14 +111,12 @@ async def sync_all_automation_events(
|
|||
async def remove_event(
|
||||
request: Request,
|
||||
eventId: str = Path(..., description="Event ID to remove"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually remove a specific event from scheduler (sysadmin only).
|
||||
Used for debugging and manual event cleanup.
|
||||
"""
|
||||
requireSysadmin(currentUser)
|
||||
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
|
||||
|
|
@ -141,9 +126,9 @@ async def remove_event(
|
|||
# Update automation's eventId if it exists
|
||||
if eventId.startswith("automation."):
|
||||
automation_id = eventId.replace("automation.", "")
|
||||
chatInterface = interfaceDbChatObjects.getInterface(currentUser)
|
||||
chatInterface = interfaceDbChat.getInterface(currentUser)
|
||||
automation = chatInterface.getAutomationDefinition(automation_id)
|
||||
if automation and automation.get("eventId") == eventId:
|
||||
if automation and getattr(automation, "eventId", None) == eventId:
|
||||
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
||||
|
||||
return {
|
||||
|
|
@ -157,4 +142,3 @@ async def remove_event(
|
|||
status_code=500,
|
||||
detail=f"Error removing event: {str(e)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
1407
modules/routes/routeAdminFeatures.py
Normal file
1407
modules/routes/routeAdminFeatures.py
Normal file
File diff suppressed because it is too large
Load diff
608
modules/routes/routeAdminRbacExport.py
Normal file
608
modules/routes/routeAdminRbacExport.py
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
RBAC export/import routes for the backend API.
|
||||
Implements endpoints for exporting and importing RBAC configurations.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Global templates: SysAdmin can export/import
|
||||
- Mandate-scoped RBAC: Mandate Admin can export/import
|
||||
- Feature instance roles: Included in mandate export
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, UploadFile, File
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelRbac import Role, AccessRule
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/rbac",
|
||||
tags=["RBAC Export/Import"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Models
|
||||
# =============================================================================
|
||||
|
||||
class RoleExport(BaseModel):
|
||||
"""Export model for a role with its access rules"""
|
||||
roleLabel: str
|
||||
description: Dict[str, str]
|
||||
featureCode: Optional[str]
|
||||
isSystemRole: bool
|
||||
accessRules: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class RbacExportData(BaseModel):
|
||||
"""Complete RBAC export data"""
|
||||
exportVersion: str = "1.0"
|
||||
exportedAt: float
|
||||
exportedBy: str
|
||||
scope: str # "global" or "mandate"
|
||||
mandateId: Optional[str]
|
||||
roles: List[RoleExport]
|
||||
|
||||
|
||||
class RbacImportResult(BaseModel):
|
||||
"""Result of RBAC import operation"""
|
||||
rolesCreated: int
|
||||
rolesUpdated: int
|
||||
rolesSkipped: int
|
||||
rulesCreated: int
|
||||
rulesUpdated: int
|
||||
errors: List[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global RBAC Export/Import (SysAdmin only)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/export/global", response_model=RbacExportData)
|
||||
@limiter.limit("10/minute")
|
||||
async def export_global_rbac(
|
||||
request: Request,
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
) -> RbacExportData:
|
||||
"""
|
||||
Export global (template) RBAC rules.
|
||||
|
||||
SysAdmin only - exports template roles that are copied to new feature instances.
|
||||
These are roles with mandateId=NULL.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get all global template roles (mandateId is NULL)
|
||||
allRoles = rootInterface.db.getRecordset(Role)
|
||||
globalRoles = [r for r in allRoles if r.get("mandateId") is None]
|
||||
|
||||
exportRoles = []
|
||||
for role in globalRoles:
|
||||
roleId = role.get("id")
|
||||
|
||||
# Get access rules for this role
|
||||
accessRules = rootInterface.db.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": roleId}
|
||||
)
|
||||
|
||||
exportRoles.append(RoleExport(
|
||||
roleLabel=role.get("roleLabel"),
|
||||
description=role.get("description", {}),
|
||||
featureCode=role.get("featureCode"),
|
||||
isSystemRole=role.get("isSystemRole", False),
|
||||
accessRules=[
|
||||
{
|
||||
"context": r.get("context"),
|
||||
"item": r.get("item"),
|
||||
"view": r.get("view", False),
|
||||
"read": r.get("read"),
|
||||
"create": r.get("create"),
|
||||
"update": r.get("update"),
|
||||
"delete": r.get("delete")
|
||||
}
|
||||
for r in accessRules
|
||||
]
|
||||
))
|
||||
|
||||
logger.info(f"SysAdmin {sysAdmin.id} exported global RBAC ({len(exportRoles)} roles)")
|
||||
|
||||
return RbacExportData(
|
||||
exportedAt=getUtcTimestamp(),
|
||||
exportedBy=str(sysAdmin.id),
|
||||
scope="global",
|
||||
mandateId=None,
|
||||
roles=exportRoles
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting global RBAC: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export RBAC: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/global", response_model=RbacImportResult)
|
||||
@limiter.limit("5/minute")
|
||||
async def import_global_rbac(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
||||
updateExisting: bool = False,
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
) -> RbacImportResult:
|
||||
"""
|
||||
Import global (template) RBAC rules.
|
||||
|
||||
SysAdmin only - imports template roles and their access rules.
|
||||
|
||||
Args:
|
||||
file: JSON file containing RbacExportData
|
||||
updateExisting: If True, update existing roles. If False, skip them.
|
||||
"""
|
||||
try:
|
||||
# Read and parse file
|
||||
content = await file.read()
|
||||
try:
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON: {str(e)}"
|
||||
)
|
||||
|
||||
# Validate structure
|
||||
if "roles" not in data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing 'roles' field in import data"
|
||||
)
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
result = RbacImportResult(
|
||||
rolesCreated=0,
|
||||
rolesUpdated=0,
|
||||
rolesSkipped=0,
|
||||
rulesCreated=0,
|
||||
rulesUpdated=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
for roleData in data.get("roles", []):
|
||||
try:
|
||||
roleLabel = roleData.get("roleLabel")
|
||||
featureCode = roleData.get("featureCode")
|
||||
|
||||
if not roleLabel:
|
||||
result.errors.append(f"Role without label skipped")
|
||||
result.rolesSkipped += 1
|
||||
continue
|
||||
|
||||
# Check if role exists (global role with same label and featureCode)
|
||||
existingRoles = rootInterface.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={
|
||||
"roleLabel": roleLabel,
|
||||
"mandateId": None,
|
||||
"featureCode": featureCode
|
||||
}
|
||||
)
|
||||
|
||||
if existingRoles:
|
||||
if updateExisting:
|
||||
# Update existing role
|
||||
existingRole = existingRoles[0]
|
||||
roleId = existingRole.get("id")
|
||||
|
||||
rootInterface.db.recordModify(
|
||||
Role,
|
||||
roleId,
|
||||
{
|
||||
"description": roleData.get("description", {}),
|
||||
"isSystemRole": roleData.get("isSystemRole", False)
|
||||
}
|
||||
)
|
||||
|
||||
# Update access rules
|
||||
result.rulesUpdated += _updateAccessRules(
|
||||
rootInterface,
|
||||
roleId,
|
||||
roleData.get("accessRules", [])
|
||||
)
|
||||
|
||||
result.rolesUpdated += 1
|
||||
else:
|
||||
result.rolesSkipped += 1
|
||||
continue
|
||||
else:
|
||||
# Create new role
|
||||
newRole = Role(
|
||||
roleLabel=roleLabel,
|
||||
description=roleData.get("description", {}),
|
||||
featureCode=featureCode,
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
isSystemRole=roleData.get("isSystemRole", False)
|
||||
)
|
||||
|
||||
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
|
||||
roleId = createdRole.get("id")
|
||||
|
||||
# Create access rules
|
||||
for ruleData in roleData.get("accessRules", []):
|
||||
newRule = AccessRule(
|
||||
roleId=roleId,
|
||||
context=ruleData.get("context"),
|
||||
item=ruleData.get("item"),
|
||||
view=ruleData.get("view", False),
|
||||
read=ruleData.get("read"),
|
||||
create=ruleData.get("create"),
|
||||
update=ruleData.get("update"),
|
||||
delete=ruleData.get("delete")
|
||||
)
|
||||
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
||||
result.rulesCreated += 1
|
||||
|
||||
result.rolesCreated += 1
|
||||
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
|
||||
|
||||
logger.info(
|
||||
f"SysAdmin {sysAdmin.id} imported global RBAC: "
|
||||
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
|
||||
f"{result.rolesSkipped} skipped"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing global RBAC: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to import RBAC: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mandate RBAC Export/Import (Mandate Admin)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/export/mandate", response_model=RbacExportData)
|
||||
@limiter.limit("10/minute")
|
||||
async def export_mandate_rbac(
|
||||
request: Request,
|
||||
includeFeatureInstances: bool = True,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> RbacExportData:
|
||||
"""
|
||||
Export RBAC rules for the current mandate.
|
||||
|
||||
Requires Mandate-Admin role. Exports mandate-level roles and optionally
|
||||
feature instance roles.
|
||||
|
||||
Args:
|
||||
includeFeatureInstances: Include feature instance roles in export
|
||||
"""
|
||||
if not context.mandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to export RBAC"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get mandate-level roles
|
||||
allRoles = rootInterface.db.getRecordset(Role)
|
||||
mandateRoles = [
|
||||
r for r in allRoles
|
||||
if str(r.get("mandateId")) == str(context.mandateId)
|
||||
]
|
||||
|
||||
# Filter by feature instance if not including them
|
||||
if not includeFeatureInstances:
|
||||
mandateRoles = [r for r in mandateRoles if not r.get("featureInstanceId")]
|
||||
|
||||
exportRoles = []
|
||||
for role in mandateRoles:
|
||||
roleId = role.get("id")
|
||||
|
||||
# Get access rules for this role
|
||||
accessRules = rootInterface.db.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": roleId}
|
||||
)
|
||||
|
||||
exportRoles.append(RoleExport(
|
||||
roleLabel=role.get("roleLabel"),
|
||||
description=role.get("description", {}),
|
||||
featureCode=role.get("featureCode"),
|
||||
isSystemRole=role.get("isSystemRole", False),
|
||||
accessRules=[
|
||||
{
|
||||
"context": r.get("context"),
|
||||
"item": r.get("item"),
|
||||
"view": r.get("view", False),
|
||||
"read": r.get("read"),
|
||||
"create": r.get("create"),
|
||||
"update": r.get("update"),
|
||||
"delete": r.get("delete")
|
||||
}
|
||||
for r in accessRules
|
||||
]
|
||||
))
|
||||
|
||||
logger.info(
|
||||
f"User {context.user.id} exported mandate {context.mandateId} RBAC "
|
||||
f"({len(exportRoles)} roles)"
|
||||
)
|
||||
|
||||
return RbacExportData(
|
||||
exportedAt=getUtcTimestamp(),
|
||||
exportedBy=str(context.user.id),
|
||||
scope="mandate",
|
||||
mandateId=str(context.mandateId),
|
||||
roles=exportRoles
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting mandate RBAC: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export RBAC: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/mandate", response_model=RbacImportResult)
|
||||
@limiter.limit("5/minute")
|
||||
async def import_mandate_rbac(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
||||
updateExisting: bool = False,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> RbacImportResult:
|
||||
"""
|
||||
Import RBAC rules for the current mandate.
|
||||
|
||||
Requires Mandate-Admin role. Imports roles as mandate-level roles
|
||||
(not feature instance roles - those are created via template copying).
|
||||
|
||||
Args:
|
||||
file: JSON file containing RbacExportData
|
||||
updateExisting: If True, update existing roles. If False, skip them.
|
||||
"""
|
||||
if not context.mandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to import RBAC"
|
||||
)
|
||||
|
||||
try:
|
||||
# Read and parse file
|
||||
content = await file.read()
|
||||
try:
|
||||
data = json.loads(content.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON: {str(e)}"
|
||||
)
|
||||
|
||||
# Validate structure
|
||||
if "roles" not in data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing 'roles' field in import data"
|
||||
)
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
result = RbacImportResult(
|
||||
rolesCreated=0,
|
||||
rolesUpdated=0,
|
||||
rolesSkipped=0,
|
||||
rulesCreated=0,
|
||||
rulesUpdated=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
for roleData in data.get("roles", []):
|
||||
try:
|
||||
roleLabel = roleData.get("roleLabel")
|
||||
featureCode = roleData.get("featureCode")
|
||||
|
||||
if not roleLabel:
|
||||
result.errors.append(f"Role without label skipped")
|
||||
result.rolesSkipped += 1
|
||||
continue
|
||||
|
||||
# System roles cannot be imported at mandate level
|
||||
if roleData.get("isSystemRole", False):
|
||||
result.errors.append(f"System role '{roleLabel}' skipped (SysAdmin only)")
|
||||
result.rolesSkipped += 1
|
||||
continue
|
||||
|
||||
# Check if role exists (mandate role with same label)
|
||||
existingRoles = rootInterface.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={
|
||||
"roleLabel": roleLabel,
|
||||
"mandateId": str(context.mandateId),
|
||||
"featureInstanceId": None # Only mandate-level roles
|
||||
}
|
||||
)
|
||||
|
||||
if existingRoles:
|
||||
if updateExisting:
|
||||
# Update existing role
|
||||
existingRole = existingRoles[0]
|
||||
roleId = existingRole.get("id")
|
||||
|
||||
rootInterface.db.recordModify(
|
||||
Role,
|
||||
roleId,
|
||||
{"description": roleData.get("description", {})}
|
||||
)
|
||||
|
||||
# Update access rules
|
||||
result.rulesUpdated += _updateAccessRules(
|
||||
rootInterface,
|
||||
roleId,
|
||||
roleData.get("accessRules", [])
|
||||
)
|
||||
|
||||
result.rolesUpdated += 1
|
||||
else:
|
||||
result.rolesSkipped += 1
|
||||
continue
|
||||
else:
|
||||
# Create new role at mandate level
|
||||
newRole = Role(
|
||||
roleLabel=roleLabel,
|
||||
description=roleData.get("description", {}),
|
||||
featureCode=featureCode,
|
||||
mandateId=str(context.mandateId),
|
||||
featureInstanceId=None,
|
||||
isSystemRole=False # Never create system roles via import
|
||||
)
|
||||
|
||||
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
|
||||
roleId = createdRole.get("id")
|
||||
|
||||
# Create access rules
|
||||
for ruleData in roleData.get("accessRules", []):
|
||||
newRule = AccessRule(
|
||||
roleId=roleId,
|
||||
context=ruleData.get("context"),
|
||||
item=ruleData.get("item"),
|
||||
view=ruleData.get("view", False),
|
||||
read=ruleData.get("read"),
|
||||
create=ruleData.get("create"),
|
||||
update=ruleData.get("update"),
|
||||
delete=ruleData.get("delete")
|
||||
)
|
||||
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
||||
result.rulesCreated += 1
|
||||
|
||||
result.rolesCreated += 1
|
||||
|
||||
except Exception as e:
|
||||
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
|
||||
|
||||
logger.info(
|
||||
f"User {context.user.id} imported mandate {context.mandateId} RBAC: "
|
||||
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
|
||||
f"{result.rolesSkipped} skipped"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing mandate RBAC: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to import RBAC: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||
"""
|
||||
Check if the user has mandate admin role in the current context.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
return False
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
for roleId in context.roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
roleLabel = role.get("roleLabel", "")
|
||||
# Admin role at mandate level
|
||||
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking mandate admin role: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _updateAccessRules(interface, roleId: str, newRules: List[Dict[str, Any]]) -> int:
|
||||
"""
|
||||
Update access rules for a role.
|
||||
Replaces existing rules with new ones.
|
||||
|
||||
Returns:
|
||||
Number of rules created/updated
|
||||
"""
|
||||
try:
|
||||
# Delete existing rules for this role
|
||||
existingRules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
||||
for rule in existingRules:
|
||||
interface.db.recordDelete(AccessRule, rule.get("id"))
|
||||
|
||||
# Create new rules
|
||||
count = 0
|
||||
for ruleData in newRules:
|
||||
newRule = AccessRule(
|
||||
roleId=roleId,
|
||||
context=ruleData.get("context"),
|
||||
item=ruleData.get("item"),
|
||||
view=ruleData.get("view", False),
|
||||
read=ruleData.get("read"),
|
||||
create=ruleData.get("create"),
|
||||
update=ruleData.get("update"),
|
||||
delete=ruleData.get("delete")
|
||||
)
|
||||
interface.db.recordCreate(AccessRule, newRule.model_dump())
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating access rules: {e}")
|
||||
return 0
|
||||
|
|
@ -3,20 +3,70 @@
|
|||
"""
|
||||
Admin RBAC Roles Management routes.
|
||||
Provides endpoints for managing roles and role assignments to users.
|
||||
|
||||
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||
Roles are global system resources, not mandate-specific.
|
||||
Role assignments are managed via UserMandateRole (not User.roleLabels).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
import logging
|
||||
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.auth import limiter, requireSysAdmin
|
||||
from modules.datamodels.datamodelUam import User, UserInDB
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _getUserRoleLabels(interface, userId: str) -> List[str]:
|
||||
"""
|
||||
Get role labels for a user from UserMandateRole (across all mandates).
|
||||
|
||||
Args:
|
||||
interface: Database interface
|
||||
userId: User ID
|
||||
|
||||
Returns:
|
||||
List of role labels
|
||||
"""
|
||||
roleLabels: Set[str] = set()
|
||||
|
||||
# Get all UserMandate records for this user
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
|
||||
for um in userMandates:
|
||||
userMandateId = um.get("id")
|
||||
if not userMandateId:
|
||||
continue
|
||||
|
||||
# Get all UserMandateRole records for this membership
|
||||
userMandateRoles = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": str(userMandateId)}
|
||||
)
|
||||
|
||||
for umr in userMandateRoles:
|
||||
roleId = umr.get("roleId")
|
||||
if roleId:
|
||||
# Get role by ID to get roleLabel
|
||||
role = interface.getRole(str(roleId))
|
||||
if role:
|
||||
roleLabels.add(role.roleLabel)
|
||||
|
||||
return list(roleLabels)
|
||||
|
||||
|
||||
def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
|
||||
"""
|
||||
Check if user has a specific role label (across all mandates).
|
||||
"""
|
||||
return roleLabel in _getUserRoleLabels(interface, userId)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/admin/rbac/roles",
|
||||
tags=["Admin RBAC Roles"],
|
||||
|
|
@ -24,51 +74,27 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
def _ensureAdminAccess(currentUser: User) -> None:
|
||||
"""Ensure current user has admin access to RBAC roles management."""
|
||||
interface = getInterface(currentUser)
|
||||
|
||||
# Check if user has admin or sysadmin role
|
||||
roleLabels = currentUser.roleLabels or []
|
||||
if "sysadmin" not in roleLabels and "admin" not in roleLabels:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin or sysadmin role required to manage RBAC roles"
|
||||
)
|
||||
|
||||
# Additional RBAC check: verify user has permission to update UserInDB
|
||||
# This is already covered by admin/sysadmin role check above, but we can add explicit RBAC check if needed
|
||||
# For now, admin/sysadmin role check is sufficient
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def listRoles(
|
||||
async def list_roles(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of all available roles with metadata.
|
||||
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||
|
||||
Returns:
|
||||
- List of role dictionaries with role label, description, and user count
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get all roles from database
|
||||
dbRoles = interface.getAllRoles()
|
||||
|
||||
# Get all users to count role assignments
|
||||
allUsers = interface.getUsers()
|
||||
|
||||
# Count users per role
|
||||
roleCounts: Dict[str, int] = {}
|
||||
for user in allUsers:
|
||||
for roleLabel in (user.roleLabels or []):
|
||||
roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1
|
||||
# Count role assignments from UserMandateRole table
|
||||
roleCounts = interface.countRoleAssignments()
|
||||
|
||||
# Convert Role objects to dictionaries and add user counts
|
||||
result = []
|
||||
|
|
@ -77,22 +103,10 @@ async def listRoles(
|
|||
"id": role.id,
|
||||
"roleLabel": role.roleLabel,
|
||||
"description": role.description,
|
||||
"userCount": roleCounts.get(role.roleLabel, 0),
|
||||
"userCount": roleCounts.get(str(role.id), 0),
|
||||
"isSystemRole": role.isSystemRole
|
||||
})
|
||||
|
||||
# Add any roles found in user assignments that don't exist in database
|
||||
dbRoleLabels = {role.roleLabel for role in dbRoles}
|
||||
for roleLabel, count in roleCounts.items():
|
||||
if roleLabel not in dbRoleLabels:
|
||||
result.append({
|
||||
"id": None,
|
||||
"roleLabel": roleLabel,
|
||||
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
|
||||
"userCount": count,
|
||||
"isSystemRole": False
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -107,21 +121,19 @@ async def listRoles(
|
|||
|
||||
@router.get("/options", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def getRoleOptions(
|
||||
async def get_role_options(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get role options for select dropdowns.
|
||||
Returns roles in format suitable for frontend select components.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Returns:
|
||||
- List of role option dictionaries with value and label
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get all roles from database
|
||||
dbRoles = interface.getAllRoles()
|
||||
|
|
@ -150,13 +162,14 @@ async def getRoleOptions(
|
|||
|
||||
@router.post("/", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def createRole(
|
||||
async def create_role(
|
||||
request: Request,
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new role.
|
||||
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||
|
||||
Request Body:
|
||||
- role: Role object to create
|
||||
|
|
@ -165,9 +178,7 @@ async def createRole(
|
|||
- Created role dictionary
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
createdRole = interface.createRole(role)
|
||||
|
||||
|
|
@ -195,13 +206,14 @@ async def createRole(
|
|||
|
||||
@router.get("/{roleId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def getRole(
|
||||
async def get_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a role by ID.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- roleId: Role ID
|
||||
|
|
@ -210,9 +222,7 @@ async def getRole(
|
|||
- Role dictionary
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
role = interface.getRole(roleId)
|
||||
if not role:
|
||||
|
|
@ -240,14 +250,15 @@ async def getRole(
|
|||
|
||||
@router.put("/{roleId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def updateRole(
|
||||
async def update_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing role.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- roleId: Role ID
|
||||
|
|
@ -259,9 +270,7 @@ async def updateRole(
|
|||
- Updated role dictionary
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
updatedRole = interface.updateRole(roleId, role)
|
||||
|
||||
|
|
@ -289,13 +298,14 @@ async def updateRole(
|
|||
|
||||
@router.delete("/{roleId}", response_model=Dict[str, str])
|
||||
@limiter.limit("30/minute")
|
||||
async def deleteRole(
|
||||
async def delete_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Delete a role.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- roleId: Role ID
|
||||
|
|
@ -304,9 +314,7 @@ async def deleteRole(
|
|||
- Success message
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
success = interface.deleteRole(roleId)
|
||||
if not success:
|
||||
|
|
@ -334,51 +342,60 @@ async def deleteRole(
|
|||
|
||||
@router.get("/users", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def listUsersWithRoles(
|
||||
async def list_users_with_roles(
|
||||
request: Request,
|
||||
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of users with their role assignments.
|
||||
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
|
||||
|
||||
Query Parameters:
|
||||
- roleLabel: Optional filter by role label
|
||||
- mandateId: Optional filter by mandate ID
|
||||
- mandateId: Optional filter by mandate ID (via UserMandate table)
|
||||
|
||||
Returns:
|
||||
- List of user dictionaries with role assignments
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
# Get all users (SysAdmin sees all)
|
||||
# Use db.getRecordset with UserInDB (the actual database model)
|
||||
allUsersData = interface.db.getRecordset(UserInDB)
|
||||
# Convert to User objects, filtering out sensitive fields
|
||||
users = []
|
||||
for u in allUsersData:
|
||||
cleanedUser = {k: v for k, v in u.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
users.append(User(**cleanedUser))
|
||||
|
||||
# Get users based on filters
|
||||
# Filter by mandate if specified (via UserMandate table)
|
||||
if mandateId:
|
||||
# Filter by mandate (if user has permission)
|
||||
users = interface.getUsers()
|
||||
users = [u for u in users if u.mandateId == mandateId]
|
||||
else:
|
||||
users = interface.getUsers()
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
mandateUserIds = {str(um["userId"]) for um in userMandates}
|
||||
users = [u for u in users if str(u.id) in mandateUserIds]
|
||||
|
||||
# Filter by role if specified
|
||||
# Filter by role if specified (via UserMandateRole)
|
||||
if roleLabel:
|
||||
users = [u for u in users if roleLabel in (u.roleLabels or [])]
|
||||
users = [u for u in users if _hasRoleLabel(interface, str(u.id), roleLabel)]
|
||||
|
||||
# Format response
|
||||
result = []
|
||||
for user in users:
|
||||
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"mandateId": user.mandateId,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": user.roleLabels or [],
|
||||
"roleCount": len(user.roleLabels or [])
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
@ -395,13 +412,14 @@ async def listUsersWithRoles(
|
|||
|
||||
@router.get("/users/{userId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def getUserRoles(
|
||||
async def get_user_roles(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get role assignments for a specific user.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
|
@ -410,9 +428,7 @@ async def getUserRoles(
|
|||
- User dictionary with role assignments
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
|
|
@ -422,15 +438,16 @@ async def getUserRoles(
|
|||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"mandateId": user.mandateId,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": user.roleLabels or [],
|
||||
"roleCount": len(user.roleLabels or [])
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -445,28 +462,27 @@ async def getUserRoles(
|
|||
|
||||
@router.put("/users/{userId}/roles", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def updateUserRoles(
|
||||
async def update_user_roles(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
roleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update role assignments for a specific user.
|
||||
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
||||
Request Body:
|
||||
- roleLabels: List of role labels to assign (e.g., ["admin", "user"])
|
||||
- newRoleLabels: List of role labels to assign (e.g., ["admin", "user"])
|
||||
|
||||
Returns:
|
||||
- Updated user dictionary with role assignments
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
|
|
@ -478,28 +494,57 @@ async def updateUserRoles(
|
|||
|
||||
# Validate role labels (basic validation - check against standard roles)
|
||||
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
||||
for roleLabel in roleLabels:
|
||||
for roleLabel in newRoleLabels:
|
||||
if roleLabel not in standardRoles:
|
||||
logger.warning(f"Non-standard role label assigned: {roleLabel}")
|
||||
|
||||
# Update user roles
|
||||
userData = {
|
||||
"roleLabels": roleLabels
|
||||
}
|
||||
# Get user's first mandate (for role assignment)
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
if not userMandates:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"User {userId} has no mandate memberships. Add to mandate first."
|
||||
)
|
||||
|
||||
updatedUser = interface.updateUser(userId, userData)
|
||||
userMandateId = str(userMandates[0].get("id"))
|
||||
|
||||
logger.info(f"Updated roles for user {userId}: {roleLabels}")
|
||||
# Get current roles for this mandate
|
||||
existingRoles = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId}
|
||||
)
|
||||
existingRoleIds = {str(r.get("roleId")) for r in existingRoles}
|
||||
|
||||
# Convert roleLabels to roleIds
|
||||
newRoleIds = set()
|
||||
for roleLabel in newRoleLabels:
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if role:
|
||||
newRoleIds.add(str(role.id))
|
||||
|
||||
# Remove roles that are no longer needed
|
||||
for existingRole in existingRoles:
|
||||
if str(existingRole.get("roleId")) not in newRoleIds:
|
||||
interface.db.recordDelete(UserMandateRole, str(existingRole.get("id")))
|
||||
|
||||
# Add new roles
|
||||
for roleId in newRoleIds:
|
||||
if roleId not in existingRoleIds:
|
||||
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
|
||||
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
||||
|
||||
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}")
|
||||
|
||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||
return {
|
||||
"id": updatedUser.id,
|
||||
"username": updatedUser.username,
|
||||
"email": updatedUser.email,
|
||||
"fullName": updatedUser.fullName,
|
||||
"mandateId": updatedUser.mandateId,
|
||||
"enabled": updatedUser.enabled,
|
||||
"roleLabels": updatedUser.roleLabels or [],
|
||||
"roleCount": len(updatedUser.roleLabels or [])
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -514,14 +559,15 @@ async def updateUserRoles(
|
|||
|
||||
@router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def addUserRole(
|
||||
async def add_user_role(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
roleLabel: str = Path(..., description="Role label to add"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a role to a user (if not already assigned).
|
||||
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
|
@ -531,9 +577,7 @@ async def addUserRole(
|
|||
- Updated user dictionary with role assignments
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
|
|
@ -543,33 +587,46 @@ async def addUserRole(
|
|||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
# Get current roles
|
||||
currentRoles = list(user.roleLabels or [])
|
||||
# Get role by label
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Role '{roleLabel}' not found"
|
||||
)
|
||||
|
||||
# Add role if not already present
|
||||
if roleLabel not in currentRoles:
|
||||
currentRoles.append(roleLabel)
|
||||
|
||||
# Update user roles
|
||||
userData = {
|
||||
"roleLabels": currentRoles
|
||||
}
|
||||
|
||||
updatedUser = interface.updateUser(userId, userData)
|
||||
|
||||
logger.info(f"Added role {roleLabel} to user {userId}")
|
||||
else:
|
||||
updatedUser = user
|
||||
# Get user's first mandate
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
if not userMandates:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"User {userId} has no mandate memberships. Add to mandate first."
|
||||
)
|
||||
|
||||
userMandateId = str(userMandates[0].get("id"))
|
||||
|
||||
# Check if role is already assigned
|
||||
existingAssignment = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
|
||||
)
|
||||
|
||||
if not existingAssignment:
|
||||
# Add the role
|
||||
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
|
||||
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
||||
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}")
|
||||
|
||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||
return {
|
||||
"id": updatedUser.id,
|
||||
"username": updatedUser.username,
|
||||
"email": updatedUser.email,
|
||||
"fullName": updatedUser.fullName,
|
||||
"mandateId": updatedUser.mandateId,
|
||||
"enabled": updatedUser.enabled,
|
||||
"roleLabels": updatedUser.roleLabels or [],
|
||||
"roleCount": len(updatedUser.roleLabels or [])
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -584,14 +641,15 @@ async def addUserRole(
|
|||
|
||||
@router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def removeUserRole(
|
||||
async def remove_user_role(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
roleLabel: str = Path(..., description="Role label to remove"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Remove a role from a user.
|
||||
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
|
@ -601,9 +659,7 @@ async def removeUserRole(
|
|||
- Updated user dictionary with role assignments
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
|
|
@ -613,38 +669,44 @@ async def removeUserRole(
|
|||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
# Get current roles
|
||||
currentRoles = list(user.roleLabels or [])
|
||||
# Get role by label
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Role '{roleLabel}' not found"
|
||||
)
|
||||
|
||||
# Remove role if present
|
||||
if roleLabel in currentRoles:
|
||||
currentRoles.remove(roleLabel)
|
||||
|
||||
# Ensure user has at least one role (default to "user")
|
||||
if not currentRoles:
|
||||
currentRoles = ["user"]
|
||||
logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role")
|
||||
|
||||
# Update user roles
|
||||
userData = {
|
||||
"roleLabels": currentRoles
|
||||
}
|
||||
|
||||
updatedUser = interface.updateUser(userId, userData)
|
||||
|
||||
logger.info(f"Removed role {roleLabel} from user {userId}")
|
||||
else:
|
||||
updatedUser = user
|
||||
# Remove role from all user's mandates
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
roleRemoved = False
|
||||
|
||||
for um in userMandates:
|
||||
userMandateId = str(um.get("id"))
|
||||
|
||||
# Find and delete the role assignment
|
||||
assignments = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
|
||||
)
|
||||
|
||||
for assignment in assignments:
|
||||
interface.db.recordDelete(UserMandateRole, str(assignment.get("id")))
|
||||
roleRemoved = True
|
||||
|
||||
if roleRemoved:
|
||||
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
|
||||
|
||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||
return {
|
||||
"id": updatedUser.id,
|
||||
"username": updatedUser.username,
|
||||
"email": updatedUser.email,
|
||||
"fullName": updatedUser.fullName,
|
||||
"mandateId": updatedUser.mandateId,
|
||||
"enabled": updatedUser.enabled,
|
||||
"roleLabels": updatedUser.roleLabels or [],
|
||||
"roleCount": len(updatedUser.roleLabels or [])
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -659,52 +721,72 @@ async def removeUserRole(
|
|||
|
||||
@router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def getUsersWithRole(
|
||||
async def get_users_with_role(
|
||||
request: Request,
|
||||
roleLabel: str = Path(..., description="Role label"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all users with a specific role.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- roleLabel: Role label
|
||||
|
||||
Query Parameters:
|
||||
- mandateId: Optional filter by mandate ID
|
||||
- mandateId: Optional filter by mandate ID (via UserMandate table)
|
||||
|
||||
Returns:
|
||||
- List of users with the specified role
|
||||
"""
|
||||
try:
|
||||
_ensureAdminAccess(currentUser)
|
||||
interface = getRootInterface()
|
||||
|
||||
interface = getInterface(currentUser)
|
||||
# Get role by label
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Role '{roleLabel}' not found"
|
||||
)
|
||||
|
||||
# Get all users
|
||||
users = interface.getUsers()
|
||||
# Get all UserMandateRole assignments for this role
|
||||
roleAssignments = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"roleId": str(role.id)}
|
||||
)
|
||||
|
||||
# Filter by role
|
||||
users = [u for u in users if roleLabel in (u.roleLabels or [])]
|
||||
# Get unique userMandateIds
|
||||
userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments}
|
||||
|
||||
# Filter by mandate if specified
|
||||
if mandateId:
|
||||
users = [u for u in users if u.mandateId == mandateId]
|
||||
# Get userIds from UserMandate records
|
||||
userIds: Set[str] = set()
|
||||
for userMandateId in userMandateIds:
|
||||
umRecords = interface.db.getRecordset(UserMandate, recordFilter={"id": userMandateId})
|
||||
if umRecords:
|
||||
um = umRecords[0]
|
||||
# Filter by mandate if specified
|
||||
if mandateId and str(um.get("mandateId")) != mandateId:
|
||||
continue
|
||||
userIds.add(str(um.get("userId")))
|
||||
|
||||
# Format response
|
||||
# Get users and format response
|
||||
result = []
|
||||
for user in users:
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"mandateId": user.mandateId,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": user.roleLabels or [],
|
||||
"roleCount": len(user.roleLabels or [])
|
||||
})
|
||||
for userId in userIds:
|
||||
user = interface.getUser(userId)
|
||||
if user:
|
||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||
result.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
"roleLabels": userRoleLabels,
|
||||
"roleCount": len(userRoleLabels)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
503
modules/routes/routeAdminUserAccessOverview.py
Normal file
503
modules/routes/routeAdminUserAccessOverview.py
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Admin User Access Overview routes.
|
||||
Provides endpoints for viewing complete user access permissions.
|
||||
|
||||
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||
Shows comprehensive view of what a user can see and access.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
import logging
|
||||
|
||||
from modules.auth import limiter, requireSysAdmin
|
||||
from modules.datamodels.datamodelUam import User, UserInDB
|
||||
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
||||
from modules.datamodels.datamodelMembership import (
|
||||
UserMandate,
|
||||
UserMandateRole,
|
||||
FeatureAccess,
|
||||
FeatureAccessRole,
|
||||
)
|
||||
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/admin/user-access-overview",
|
||||
tags=["Admin User Access Overview"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
def _getAccessLevelLabel(level: Optional[str]) -> str:
|
||||
"""Convert access level code to human-readable label."""
|
||||
labels = {
|
||||
"a": "ALL",
|
||||
"m": "MY",
|
||||
"g": "GROUP",
|
||||
"n": "NONE",
|
||||
None: "-"
|
||||
}
|
||||
return labels.get(level, "-")
|
||||
|
||||
|
||||
def _getRoleScope(role: Dict[str, Any]) -> str:
|
||||
"""Determine the scope of a role."""
|
||||
if role.get("featureInstanceId"):
|
||||
return "instance"
|
||||
elif role.get("mandateId"):
|
||||
return "mandate"
|
||||
else:
|
||||
return "global"
|
||||
|
||||
|
||||
def _getRoleScopePriority(scope: str) -> int:
|
||||
"""Get priority for role scope (higher = more specific)."""
|
||||
priorities = {"global": 1, "mandate": 2, "instance": 3}
|
||||
return priorities.get(scope, 0)
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def listUsersForOverview(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of all users for selection in the overview.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Returns:
|
||||
- List of user dictionaries with basic info
|
||||
"""
|
||||
try:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get all users
|
||||
allUsersData = interface.db.getRecordset(UserInDB)
|
||||
|
||||
result = []
|
||||
for u in allUsersData:
|
||||
result.append({
|
||||
"id": u.get("id"),
|
||||
"username": u.get("username"),
|
||||
"email": u.get("email"),
|
||||
"fullName": u.get("fullName"),
|
||||
"isSysAdmin": u.get("isSysAdmin", False),
|
||||
"enabled": u.get("enabled", True),
|
||||
})
|
||||
|
||||
# Sort by username
|
||||
result.sort(key=lambda x: (x.get("username") or "").lower())
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing users for overview: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list users: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{userId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def getUserAccessOverview(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID to get access overview for"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
||||
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive access overview for a specific user.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
||||
Query Parameters:
|
||||
- mandateId: Optional filter by mandate ID
|
||||
- featureInstanceId: Optional filter by feature instance ID
|
||||
|
||||
Returns:
|
||||
- Comprehensive access overview including:
|
||||
- User info
|
||||
- All assigned roles with scope
|
||||
- UI access (what pages/views the user can see)
|
||||
- Data access (what tables/fields the user can access)
|
||||
- Resource access (what resources the user can use)
|
||||
"""
|
||||
try:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
# Build user info
|
||||
userInfo = {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"isSysAdmin": user.isSysAdmin,
|
||||
"enabled": user.enabled,
|
||||
}
|
||||
|
||||
# If user is SysAdmin, they have full access to everything
|
||||
if user.isSysAdmin:
|
||||
return {
|
||||
"user": userInfo,
|
||||
"isSysAdmin": True,
|
||||
"sysAdminNote": "SysAdmin users have full access to all system-level resources without mandate context.",
|
||||
"roles": [],
|
||||
"mandates": [],
|
||||
"uiAccess": [],
|
||||
"dataAccess": [],
|
||||
"resourceAccess": [],
|
||||
}
|
||||
|
||||
# Collect all roles for the user
|
||||
allRoles = []
|
||||
roleIdToInfo = {} # Map roleId to role info for later reference
|
||||
|
||||
# Get mandates for this user
|
||||
mandateFilter = {"userId": userId, "enabled": True}
|
||||
if mandateId:
|
||||
mandateFilter["mandateId"] = mandateId
|
||||
|
||||
userMandates = interface.db.getRecordset(UserMandate, recordFilter=mandateFilter)
|
||||
|
||||
mandatesInfo = []
|
||||
for um in userMandates:
|
||||
umId = um.get("id")
|
||||
umMandateId = um.get("mandateId")
|
||||
|
||||
# Get mandate name
|
||||
mandate = interface.getMandate(umMandateId)
|
||||
mandateName = mandate.name if mandate else umMandateId
|
||||
|
||||
# Get roles for this UserMandate
|
||||
umRoles = interface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": umId}
|
||||
)
|
||||
|
||||
mandateRoleIds = []
|
||||
for umr in umRoles:
|
||||
roleId = umr.get("roleId")
|
||||
if roleId:
|
||||
mandateRoleIds.append(roleId)
|
||||
|
||||
# Get role details
|
||||
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
scope = _getRoleScope(role)
|
||||
roleInfo = {
|
||||
"id": roleId,
|
||||
"roleLabel": role.get("roleLabel"),
|
||||
"description": role.get("description", {}),
|
||||
"scope": scope,
|
||||
"scopePriority": _getRoleScopePriority(scope),
|
||||
"mandateId": role.get("mandateId"),
|
||||
"featureInstanceId": role.get("featureInstanceId"),
|
||||
"source": "mandate",
|
||||
"sourceMandateId": umMandateId,
|
||||
"sourceMandateName": mandateName,
|
||||
}
|
||||
allRoles.append(roleInfo)
|
||||
roleIdToInfo[roleId] = roleInfo
|
||||
|
||||
# Get feature instances for this mandate
|
||||
featureInstanceFilter = {"userId": userId, "enabled": True}
|
||||
featureAccesses = interface.db.getRecordset(FeatureAccess, recordFilter=featureInstanceFilter)
|
||||
|
||||
featureInstancesInfo = []
|
||||
for fa in featureAccesses:
|
||||
faId = fa.get("id")
|
||||
faInstanceId = fa.get("featureInstanceId")
|
||||
|
||||
# Check if instance belongs to this mandate
|
||||
instance = interface.db.getRecordset(FeatureInstance, recordFilter={"id": faInstanceId})
|
||||
if not instance:
|
||||
continue
|
||||
instance = instance[0]
|
||||
|
||||
if instance.get("mandateId") != umMandateId:
|
||||
continue
|
||||
|
||||
# Filter by featureInstanceId if specified
|
||||
if featureInstanceId and faInstanceId != featureInstanceId:
|
||||
continue
|
||||
|
||||
# Get feature info
|
||||
featureCode = instance.get("featureCode")
|
||||
featureRecords = interface.db.getRecordset(Feature, recordFilter={"code": featureCode})
|
||||
featureLabel = featureRecords[0].get("label", {}) if featureRecords else {}
|
||||
|
||||
# Get roles for this FeatureAccess
|
||||
faRoles = interface.db.getRecordset(
|
||||
FeatureAccessRole,
|
||||
recordFilter={"featureAccessId": faId}
|
||||
)
|
||||
|
||||
instanceRoleIds = []
|
||||
for far in faRoles:
|
||||
roleId = far.get("roleId")
|
||||
if roleId:
|
||||
instanceRoleIds.append(roleId)
|
||||
|
||||
# Get role details (if not already added)
|
||||
if roleId not in roleIdToInfo:
|
||||
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
scope = _getRoleScope(role)
|
||||
roleInfo = {
|
||||
"id": roleId,
|
||||
"roleLabel": role.get("roleLabel"),
|
||||
"description": role.get("description", {}),
|
||||
"scope": scope,
|
||||
"scopePriority": _getRoleScopePriority(scope),
|
||||
"mandateId": role.get("mandateId"),
|
||||
"featureInstanceId": role.get("featureInstanceId"),
|
||||
"source": "featureInstance",
|
||||
"sourceInstanceId": faInstanceId,
|
||||
"sourceInstanceLabel": instance.get("label"),
|
||||
}
|
||||
allRoles.append(roleInfo)
|
||||
roleIdToInfo[roleId] = roleInfo
|
||||
|
||||
featureInstancesInfo.append({
|
||||
"id": faInstanceId,
|
||||
"label": instance.get("label"),
|
||||
"featureCode": featureCode,
|
||||
"featureLabel": featureLabel,
|
||||
"roleIds": instanceRoleIds,
|
||||
})
|
||||
|
||||
mandatesInfo.append({
|
||||
"id": umMandateId,
|
||||
"name": mandateName,
|
||||
"roleIds": mandateRoleIds,
|
||||
"featureInstances": featureInstancesInfo,
|
||||
})
|
||||
|
||||
# Remove duplicate roles (keep most specific)
|
||||
uniqueRoles = {}
|
||||
for role in allRoles:
|
||||
roleId = role["id"]
|
||||
if roleId not in uniqueRoles or role["scopePriority"] > uniqueRoles[roleId]["scopePriority"]:
|
||||
uniqueRoles[roleId] = role
|
||||
|
||||
allRoles = list(uniqueRoles.values())
|
||||
|
||||
# Get all AccessRules for all role IDs
|
||||
allRoleIds = list(roleIdToInfo.keys())
|
||||
|
||||
# Collect access by context
|
||||
uiAccess = []
|
||||
dataAccess = []
|
||||
resourceAccess = []
|
||||
|
||||
for roleId in allRoleIds:
|
||||
roleInfo = roleIdToInfo.get(roleId, {})
|
||||
roleLabel = roleInfo.get("roleLabel", "unknown")
|
||||
roleScope = roleInfo.get("scope", "unknown")
|
||||
|
||||
# Get all rules for this role
|
||||
rules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
||||
|
||||
for rule in rules:
|
||||
context = rule.get("context")
|
||||
item = rule.get("item")
|
||||
|
||||
accessEntry = {
|
||||
"item": item or "(all)",
|
||||
"grantedByRoleId": roleId,
|
||||
"grantedByRoleLabel": roleLabel,
|
||||
"roleScope": roleScope,
|
||||
"scopePriority": roleInfo.get("scopePriority", 0),
|
||||
}
|
||||
|
||||
if context == "UI":
|
||||
accessEntry["view"] = rule.get("view", False)
|
||||
if accessEntry["view"]:
|
||||
uiAccess.append(accessEntry)
|
||||
|
||||
elif context == "DATA":
|
||||
accessEntry["view"] = rule.get("view", False)
|
||||
accessEntry["read"] = _getAccessLevelLabel(rule.get("read"))
|
||||
accessEntry["create"] = _getAccessLevelLabel(rule.get("create"))
|
||||
accessEntry["update"] = _getAccessLevelLabel(rule.get("update"))
|
||||
accessEntry["delete"] = _getAccessLevelLabel(rule.get("delete"))
|
||||
dataAccess.append(accessEntry)
|
||||
|
||||
elif context == "RESOURCE":
|
||||
accessEntry["view"] = rule.get("view", False)
|
||||
if accessEntry["view"]:
|
||||
resourceAccess.append(accessEntry)
|
||||
|
||||
# Merge and deduplicate access entries (keep highest priority)
|
||||
def _mergeAccessEntries(entries: List[Dict], isDataContext: bool = False) -> List[Dict]:
|
||||
"""Merge entries for same item, keeping highest priority."""
|
||||
merged = {}
|
||||
for entry in entries:
|
||||
item = entry["item"]
|
||||
priority = entry.get("scopePriority", 0)
|
||||
|
||||
if item not in merged or priority > merged[item].get("scopePriority", 0):
|
||||
merged[item] = entry
|
||||
elif item in merged and priority == merged[item].get("scopePriority", 0):
|
||||
# Same priority - merge grantedBy info
|
||||
existingRoles = merged[item].get("grantedByRoleLabels", [merged[item].get("grantedByRoleLabel")])
|
||||
if entry["grantedByRoleLabel"] not in existingRoles:
|
||||
existingRoles.append(entry["grantedByRoleLabel"])
|
||||
merged[item]["grantedByRoleLabels"] = existingRoles
|
||||
|
||||
# For DATA context, merge to most permissive
|
||||
if isDataContext:
|
||||
levelOrder = {"NONE": 0, "-": 0, "MY": 1, "GROUP": 2, "ALL": 3}
|
||||
for field in ["read", "create", "update", "delete"]:
|
||||
existingLevel = merged[item].get(field, "-")
|
||||
newLevel = entry.get(field, "-")
|
||||
if levelOrder.get(newLevel, 0) > levelOrder.get(existingLevel, 0):
|
||||
merged[item][field] = newLevel
|
||||
|
||||
# Clean up and sort
|
||||
result = list(merged.values())
|
||||
for entry in result:
|
||||
if "grantedByRoleLabels" not in entry:
|
||||
entry["grantedByRoleLabels"] = [entry.get("grantedByRoleLabel")]
|
||||
# Remove internal priority field from response
|
||||
entry.pop("scopePriority", None)
|
||||
|
||||
result.sort(key=lambda x: x.get("item", ""))
|
||||
return result
|
||||
|
||||
uiAccess = _mergeAccessEntries(uiAccess)
|
||||
dataAccess = _mergeAccessEntries(dataAccess, isDataContext=True)
|
||||
resourceAccess = _mergeAccessEntries(resourceAccess)
|
||||
|
||||
# Clean up roles for response
|
||||
for role in allRoles:
|
||||
role.pop("scopePriority", None)
|
||||
|
||||
# Sort roles by scope (instance > mandate > global) then by label
|
||||
allRoles.sort(key=lambda r: (-_getRoleScopePriority(r.get("scope", "")), r.get("roleLabel", "").lower()))
|
||||
|
||||
return {
|
||||
"user": userInfo,
|
||||
"isSysAdmin": False,
|
||||
"roles": allRoles,
|
||||
"mandates": mandatesInfo,
|
||||
"uiAccess": uiAccess,
|
||||
"dataAccess": dataAccess,
|
||||
"resourceAccess": resourceAccess,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user access overview: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get user access overview: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{userId}/effective-permissions", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def getEffectivePermissions(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
mandateId: str = Query(..., description="Mandate ID context"),
|
||||
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
|
||||
context: str = Query("DATA", description="Context type: DATA, UI, or RESOURCE"),
|
||||
item: Optional[str] = Query(None, description="Specific item to check permissions for"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get effective (resolved) permissions for a user in a specific context.
|
||||
This uses the RBAC resolution logic to show what permissions actually apply.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
||||
Query Parameters:
|
||||
- mandateId: Required mandate context
|
||||
- featureInstanceId: Optional feature instance context
|
||||
- context: Permission context (DATA, UI, RESOURCE)
|
||||
- item: Optional specific item to check
|
||||
|
||||
Returns:
|
||||
- Effective permissions after RBAC resolution
|
||||
"""
|
||||
try:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get user
|
||||
user = interface.getUser(userId)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
# Convert context string to enum
|
||||
try:
|
||||
contextEnum = AccessRuleContext(context)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid context: {context}. Must be DATA, UI, or RESOURCE."
|
||||
)
|
||||
|
||||
# Use RBAC interface to get actual permissions
|
||||
from modules.security.rbac import RbacClass
|
||||
rbac = RbacClass(interface.db, dbApp=interface.db)
|
||||
|
||||
permissions = rbac.getUserPermissions(
|
||||
user=user,
|
||||
context=contextEnum,
|
||||
item=item or "",
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=featureInstanceId
|
||||
)
|
||||
|
||||
return {
|
||||
"userId": userId,
|
||||
"mandateId": mandateId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"context": context,
|
||||
"item": item,
|
||||
"effectivePermissions": {
|
||||
"view": permissions.view,
|
||||
"read": _getAccessLevelLabel(permissions.read.value if permissions.read else None),
|
||||
"create": _getAccessLevelLabel(permissions.create.value if permissions.create else None),
|
||||
"update": _getAccessLevelLabel(permissions.update.value if permissions.update else None),
|
||||
"delete": _getAccessLevelLabel(permissions.delete.value if permissions.delete else None),
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting effective permissions: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get effective permissions: {str(e)}"
|
||||
)
|
||||
|
|
@ -10,17 +10,16 @@ from typing import Optional, Dict, Any
|
|||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
|
||||
|
||||
# Import auth modules
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||
from modules.interfaces import interfaceDbChat
|
||||
|
||||
# Import models
|
||||
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
||||
# Import workflow control functions
|
||||
from modules.features.workflow import chatStart, chatStop
|
||||
from modules.workflows.automation import chatStart, chatStop
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -32,8 +31,8 @@ router = APIRouter(
|
|||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
def getServiceChat(currentUser: User):
|
||||
return interfaceDbChatObjects.getInterface(currentUser)
|
||||
def _getServiceChat(context: RequestContext):
|
||||
return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
|
||||
# Workflow start endpoint
|
||||
@router.post("/start", response_model=ChatWorkflow)
|
||||
|
|
@ -43,7 +42,7 @@ async def start_workflow(
|
|||
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"),
|
||||
workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"),
|
||||
userInput: UserInputRequest = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""
|
||||
Starts a new workflow or continues an existing one.
|
||||
|
|
@ -54,7 +53,8 @@ async def start_workflow(
|
|||
"""
|
||||
try:
|
||||
# Start or continue workflow using playground controller
|
||||
workflow = await chatStart(currentUser, userInput, workflowMode, workflowId)
|
||||
mandateId = str(context.mandateId) if context.mandateId else None
|
||||
workflow = await chatStart(context.user, userInput, workflowMode, workflowId, mandateId=mandateId)
|
||||
|
||||
return workflow
|
||||
|
||||
|
|
@ -71,12 +71,13 @@ async def start_workflow(
|
|||
async def stop_workflow(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Stops a running workflow."""
|
||||
try:
|
||||
# Stop workflow using playground controller
|
||||
workflow = await chatStop(currentUser, workflowId)
|
||||
mandateId = str(context.mandateId) if context.mandateId else None
|
||||
workflow = await chatStop(context.user, workflowId, mandateId=mandateId)
|
||||
|
||||
return workflow
|
||||
|
||||
|
|
@ -94,7 +95,7 @@ async def get_workflow_chat_data(
|
|||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow"),
|
||||
afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer.
|
||||
|
|
@ -102,7 +103,7 @@ async def get_workflow_chat_data(
|
|||
"""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = _getServiceChat(context)
|
||||
|
||||
# Verify workflow exists
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
@ -20,10 +20,11 @@ import math
|
|||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.auth.tokenRefreshService import token_refresh_service
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
|
||||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -92,6 +93,52 @@ router = APIRouter(
|
|||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONS ENDPOINTS (for dropdowns)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/statuses/options", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def get_connection_status_options(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get connection status options for select dropdowns.
|
||||
Returns standardized format: [{ value, label }]
|
||||
"""
|
||||
return [
|
||||
{"value": status.value, "label": status.value.capitalize()}
|
||||
for status in ConnectionStatus
|
||||
]
|
||||
|
||||
|
||||
@router.get("/authorities/options", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def get_auth_authority_options(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get authentication authority options for select dropdowns.
|
||||
Returns standardized format: [{ value, label }]
|
||||
"""
|
||||
authorityLabels = {
|
||||
"local": "Local",
|
||||
"google": "Google",
|
||||
"msft": "Microsoft"
|
||||
}
|
||||
return [
|
||||
{"value": auth.value, "label": authorityLabels.get(auth.value, auth.value)}
|
||||
for auth in AuthAuthority
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CRUD ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse[UserConnection])
|
||||
@limiter.limit("30/minute")
|
||||
async def get_connections(
|
||||
|
|
@ -136,7 +183,6 @@ async def get_connections(
|
|||
|
||||
# Perform silent token refresh for expired OAuth connections
|
||||
try:
|
||||
from modules.auth import token_refresh_service
|
||||
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
|
||||
if refresh_result.get("refreshed", 0) > 0:
|
||||
logger.info(f"Silently refreshed {refresh_result['refreshed']} tokens for user {currentUser.id}")
|
||||
|
|
@ -285,13 +331,8 @@ async def create_connection(
|
|||
detail=f"Unsupported connection type: {connection_data.get('type')}"
|
||||
)
|
||||
|
||||
# Get fresh copy of user from database
|
||||
user = interface.getUser(currentUser.id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
# Note: currentUser is already authenticated via JWT - no need to re-verify from database
|
||||
# The getCurrentUser dependency already validated the user exists
|
||||
|
||||
# Always create a new connection with PENDING status
|
||||
connection = interface.addUserConnection(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import json
|
|||
from modules.auth import limiter, getCurrentUser
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||
from modules.datamodels.datamodelFiles import FileItem, FilePreview
|
||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||
from modules.datamodels.datamodelUam import User
|
||||
|
|
@ -69,7 +69,7 @@ async def get_files(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getAllFiles(pagination=paginationParams)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult
|
||||
|
|
@ -112,17 +112,17 @@ async def upload_file(
|
|||
file.fileName = file.filename
|
||||
"""Upload a file"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Read file
|
||||
fileContent = await file.read()
|
||||
|
||||
# Check size limits
|
||||
maxSize = int(interfaceDbComponentObjects.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes
|
||||
maxSize = int(interfaceDbManagement.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes
|
||||
if len(fileContent) > maxSize:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File too large. Maximum size: {interfaceDbComponentObjects.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB"
|
||||
detail=f"File too large. Maximum size: {interfaceDbManagement.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB"
|
||||
)
|
||||
|
||||
# Save file via LucyDOM interface in the database
|
||||
|
|
@ -136,15 +136,13 @@ async def upload_file(
|
|||
else: # new_file
|
||||
message = "File uploaded successfully"
|
||||
|
||||
# If workflowId is provided, update the file information
|
||||
if workflowId:
|
||||
updateData = {"workflowId": workflowId}
|
||||
managementInterface.updateFile(fileItem.id, updateData)
|
||||
fileItem.workflowId = workflowId
|
||||
|
||||
# Convert FileItem to dictionary for JSON response
|
||||
fileMeta = fileItem.model_dump()
|
||||
|
||||
# If workflowId is provided, include it in the response (not stored in FileItem model)
|
||||
if workflowId:
|
||||
fileMeta["workflowId"] = workflowId
|
||||
|
||||
# Response with duplicate information
|
||||
return JSONResponse({
|
||||
"message": message,
|
||||
|
|
@ -155,7 +153,7 @@ async def upload_file(
|
|||
"isDuplicate": duplicateType != "new_file"
|
||||
})
|
||||
|
||||
except interfaceDbComponentObjects.FileStorageError as e:
|
||||
except interfaceDbManagement.FileStorageError as e:
|
||||
logger.error(f"Error during file upload (storage): {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -177,7 +175,7 @@ async def get_file(
|
|||
) -> FileItem:
|
||||
"""Get a file"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get file via LucyDOM interface from the database
|
||||
fileData = managementInterface.getFile(fileId)
|
||||
|
|
@ -189,19 +187,19 @@ async def get_file(
|
|||
|
||||
return fileData
|
||||
|
||||
except interfaceDbComponentObjects.FileNotFoundError as e:
|
||||
except interfaceDbManagement.FileNotFoundError as e:
|
||||
logger.warning(f"File not found: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
except interfaceDbComponentObjects.FilePermissionError as e:
|
||||
except interfaceDbManagement.FilePermissionError as e:
|
||||
logger.warning(f"No permission for file: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e)
|
||||
)
|
||||
except interfaceDbComponentObjects.FileError as e:
|
||||
except interfaceDbManagement.FileError as e:
|
||||
logger.error(f"Error retrieving file: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -224,7 +222,7 @@ async def update_file(
|
|||
) -> FileItem:
|
||||
"""Update file info"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get the file from the database
|
||||
file = managementInterface.getFile(fileId)
|
||||
|
|
@ -270,7 +268,7 @@ async def delete_file(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a file"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Check if the file exists
|
||||
existingFile = managementInterface.getFile(fileId)
|
||||
|
|
@ -297,7 +295,7 @@ async def get_file_stats(
|
|||
) -> Dict[str, Any]:
|
||||
"""Returns statistics about the stored files"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get all files - metadata only
|
||||
allFiles = managementInterface.getAllFiles()
|
||||
|
|
@ -336,7 +334,7 @@ async def download_file(
|
|||
) -> Response:
|
||||
"""Download a file"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get file data
|
||||
fileData = managementInterface.getFile(fileId)
|
||||
|
|
@ -384,7 +382,7 @@ async def preview_file(
|
|||
) -> FilePreview:
|
||||
"""Preview a file's content"""
|
||||
try:
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get file preview using the correct method
|
||||
preview = managementInterface.getFileContent(fileId)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"""
|
||||
Mandate routes for the backend API.
|
||||
Implements the endpoints for mandate management.
|
||||
|
||||
MULTI-TENANT:
|
||||
- Mandate CRUD is SysAdmin-only (mandates are system resources)
|
||||
- User management within mandates is Mandate-Admin (add/remove users)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||
|
|
@ -10,18 +14,53 @@ from typing import List, Dict, Any, Optional
|
|||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
|
||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
|
||||
# Import the model classes
|
||||
from modules.datamodels.datamodelUam import Mandate, User
|
||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Models for User Management
|
||||
# =============================================================================
|
||||
|
||||
class UserMandateCreate(BaseModel):
|
||||
"""Request model for adding a user to a mandate"""
|
||||
targetUserId: str = Field(..., description="User ID to add to the mandate")
|
||||
roleIds: List[str] = Field(..., description="Role IDs to assign to the user")
|
||||
|
||||
|
||||
class UserMandateResponse(BaseModel):
|
||||
"""Response model for user mandate membership"""
|
||||
id: str # UserMandate ID as primary key
|
||||
userId: str
|
||||
mandateId: str
|
||||
roleIds: List[str]
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MandateUserInfo(BaseModel):
|
||||
"""User info within a mandate context"""
|
||||
id: str # UserMandate ID as primary key
|
||||
userId: str
|
||||
username: str
|
||||
email: Optional[str]
|
||||
fullName: Optional[str]
|
||||
roleIds: List[str]
|
||||
roleLabels: List[str] # Resolved role labels for display
|
||||
enabled: bool
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -40,10 +79,11 @@ router = APIRouter(
|
|||
async def get_mandates(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> PaginatedResponse[Mandate]:
|
||||
"""
|
||||
Get mandates with optional pagination, sorting, and filtering.
|
||||
MULTI-TENANT: SysAdmin-only (mandates are system resources).
|
||||
|
||||
Query Parameters:
|
||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||
|
|
@ -67,7 +107,7 @@ async def get_mandates(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getRootInterface()
|
||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult
|
||||
|
|
@ -103,11 +143,14 @@ async def get_mandates(
|
|||
async def get_mandate(
|
||||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Mandate:
|
||||
"""Get a specific mandate by ID"""
|
||||
"""
|
||||
Get a specific mandate by ID.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getRootInterface()
|
||||
mandate = appInterface.getMandate(mandateId)
|
||||
|
||||
if not mandate:
|
||||
|
|
@ -131,9 +174,12 @@ async def get_mandate(
|
|||
async def create_mandate(
|
||||
request: Request,
|
||||
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Mandate:
|
||||
"""Create a new mandate"""
|
||||
"""
|
||||
Create a new mandate.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Creating mandate with data: {mandateData}")
|
||||
|
||||
|
|
@ -146,14 +192,16 @@ async def create_mandate(
|
|||
)
|
||||
|
||||
# Get optional fields with defaults
|
||||
language = mandateData.get('language', 'en')
|
||||
description = mandateData.get('description')
|
||||
enabled = mandateData.get('enabled', True)
|
||||
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Create mandate
|
||||
newMandate = appInterface.createMandate(
|
||||
name=name,
|
||||
language=language
|
||||
description=description,
|
||||
enabled=enabled
|
||||
)
|
||||
|
||||
if not newMandate:
|
||||
|
|
@ -161,6 +209,8 @@ async def create_mandate(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create mandate"
|
||||
)
|
||||
|
||||
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
|
||||
|
||||
return newMandate
|
||||
except HTTPException:
|
||||
|
|
@ -178,13 +228,16 @@ async def update_mandate(
|
|||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate to update"),
|
||||
mandateData: dict = Body(..., description="Mandate update data"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Mandate:
|
||||
"""Update an existing mandate"""
|
||||
"""
|
||||
Update an existing mandate.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
|
||||
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Check if mandate exists
|
||||
existingMandate = appInterface.getMandate(mandateId)
|
||||
|
|
@ -202,6 +255,8 @@ async def update_mandate(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update mandate"
|
||||
)
|
||||
|
||||
logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
|
||||
|
||||
return updatedMandate
|
||||
except HTTPException:
|
||||
|
|
@ -218,11 +273,14 @@ async def update_mandate(
|
|||
async def delete_mandate(
|
||||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a mandate"""
|
||||
"""
|
||||
Delete a mandate.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Check if mandate exists
|
||||
existingMandate = appInterface.getMandate(mandateId)
|
||||
|
|
@ -231,6 +289,12 @@ async def delete_mandate(
|
|||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Mandate {mandateId} not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Delete all UserMandate entries for this mandate first
|
||||
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
for um in userMandates:
|
||||
appInterface.db.deleteRecord(UserMandate, um["id"])
|
||||
logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}")
|
||||
|
||||
# Delete mandate
|
||||
try:
|
||||
|
|
@ -240,6 +304,8 @@ async def delete_mandate(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
|
||||
|
||||
return {"message": f"Mandate {mandateId} deleted successfully"}
|
||||
except HTTPException:
|
||||
|
|
@ -250,3 +316,557 @@ async def delete_mandate(
|
|||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete mandate: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Management within Mandates (Mandate-Admin)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{targetMandateId}/users")
|
||||
@limiter.limit("60/minute")
|
||||
async def list_mandate_users(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
):
|
||||
"""
|
||||
List all users in a mandate with pagination, search, and sorting support.
|
||||
|
||||
Requires Mandate-Admin role or SysAdmin.
|
||||
|
||||
Args:
|
||||
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
||||
"""
|
||||
# Check permission
|
||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Verify mandate exists
|
||||
mandate = rootInterface.getMandate(targetMandateId)
|
||||
if not mandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Mandate {targetMandateId} not found"
|
||||
)
|
||||
|
||||
# Parse pagination parameter
|
||||
paginationParams = None
|
||||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
# Normalize pagination dict
|
||||
if 'sort' in paginationDict and paginationDict['sort']:
|
||||
normalizedSort = []
|
||||
for item in paginationDict['sort']:
|
||||
if isinstance(item, dict):
|
||||
normalizedSort.append(item)
|
||||
paginationDict['sort'] = normalizedSort if normalizedSort else None
|
||||
paginationParams = paginationDict
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
# Get all UserMandate entries for this mandate
|
||||
userMandates = rootInterface.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"mandateId": targetMandateId}
|
||||
)
|
||||
|
||||
result = []
|
||||
for um in userMandates:
|
||||
# Get user info
|
||||
user = rootInterface.getUser(um.get("userId"))
|
||||
if not user:
|
||||
continue
|
||||
|
||||
# Get roles for this membership
|
||||
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
|
||||
|
||||
# Resolve role labels for display
|
||||
roleLabels = []
|
||||
for roleId in roleIds:
|
||||
role = rootInterface.getRole(roleId)
|
||||
if role:
|
||||
roleLabels.append(role.roleLabel)
|
||||
else:
|
||||
roleLabels.append(roleId) # Fallback to ID if not found
|
||||
|
||||
result.append({
|
||||
"id": um.get("id"), # UserMandate ID as primary key
|
||||
"userId": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"fullName": user.fullName,
|
||||
"roleIds": roleIds,
|
||||
"roleLabels": roleLabels,
|
||||
"enabled": um.get("enabled", True)
|
||||
})
|
||||
|
||||
# Apply search, filtering, and sorting if pagination requested
|
||||
if paginationParams:
|
||||
# Apply search (if search term provided)
|
||||
searchTerm = paginationParams.get('search', '').lower() if paginationParams.get('search') else ''
|
||||
if searchTerm:
|
||||
searchedResult = []
|
||||
for item in result:
|
||||
username = (item.get("username") or "").lower()
|
||||
email = (item.get("email") or "").lower()
|
||||
fullName = (item.get("fullName") or "").lower()
|
||||
roleLabelsStr = " ".join(item.get("roleLabels") or []).lower()
|
||||
|
||||
if searchTerm in username or searchTerm in email or searchTerm in fullName or searchTerm in roleLabelsStr:
|
||||
searchedResult.append(item)
|
||||
result = searchedResult
|
||||
|
||||
# Apply filters (if filters provided)
|
||||
filters = paginationParams.get('filters')
|
||||
if filters:
|
||||
for fieldName, filterValue in filters.items():
|
||||
if filterValue is not None and filterValue != '':
|
||||
filterValueLower = str(filterValue).lower()
|
||||
result = [
|
||||
item for item in result
|
||||
if str(item.get(fieldName, '')).lower() == filterValueLower
|
||||
]
|
||||
|
||||
# Apply sorting
|
||||
sortFields = paginationParams.get('sort')
|
||||
if sortFields:
|
||||
for sortItem in reversed(sortFields):
|
||||
field = sortItem.get('field')
|
||||
direction = sortItem.get('direction', 'asc')
|
||||
if field:
|
||||
result = sorted(
|
||||
result,
|
||||
key=lambda x: str(x.get(field, '') or '').lower(),
|
||||
reverse=(direction == 'desc')
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
page = paginationParams.get('page', 1)
|
||||
pageSize = paginationParams.get('pageSize', 25)
|
||||
totalItems = len(result)
|
||||
totalPages = (totalItems + pageSize - 1) // pageSize if totalItems > 0 else 0
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
paginatedResult = result[startIdx:endIdx]
|
||||
|
||||
return {
|
||||
"items": paginatedResult,
|
||||
"pagination": {
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
"totalItems": totalItems,
|
||||
"totalPages": totalPages
|
||||
}
|
||||
}
|
||||
|
||||
# No pagination - return all users as list
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing users for mandate {targetMandateId}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list users: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{targetMandateId}/users", response_model=UserMandateResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def add_user_to_mandate(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||
data: UserMandateCreate = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> UserMandateResponse:
|
||||
"""
|
||||
Add a user to a mandate with specified roles.
|
||||
|
||||
Requires Mandate-Admin role.
|
||||
SysAdmin cannot add themselves (Self-Eskalation Prevention).
|
||||
|
||||
Args:
|
||||
targetMandateId: Target mandate ID
|
||||
data: User ID and role IDs to assign
|
||||
"""
|
||||
# 1. SysAdmin Self-Eskalation Prevention
|
||||
if context.isSysAdmin and data.targetUserId == str(context.user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
|
||||
)
|
||||
|
||||
# 2. Check Mandate-Admin permission
|
||||
if not _hasMandateAdminRole(context, targetMandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to add users"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# 3. Verify mandate exists
|
||||
mandate = rootInterface.getMandate(targetMandateId)
|
||||
if not mandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Mandate {targetMandateId} not found"
|
||||
)
|
||||
|
||||
# 4. Verify target user exists
|
||||
targetUser = rootInterface.getUser(data.targetUserId)
|
||||
if not targetUser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {data.targetUserId} not found"
|
||||
)
|
||||
|
||||
# 5. Check if user is already a member
|
||||
existingMembership = rootInterface.getUserMandate(data.targetUserId, targetMandateId)
|
||||
if existingMembership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"User {data.targetUserId} is already a member of this mandate"
|
||||
)
|
||||
|
||||
# 6. Validate roles (must exist and belong to this mandate or be global)
|
||||
for roleId in data.roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if not roleRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role {roleId} not found"
|
||||
)
|
||||
role = roleRecords[0]
|
||||
roleMandateId = role.get("mandateId")
|
||||
if roleMandateId and str(roleMandateId) != str(targetMandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role {roleId} belongs to a different mandate"
|
||||
)
|
||||
|
||||
# 7. Create UserMandate
|
||||
userMandate = rootInterface.createUserMandate(
|
||||
userId=data.targetUserId,
|
||||
mandateId=targetMandateId,
|
||||
roleIds=data.roleIds
|
||||
)
|
||||
|
||||
# 8. Audit - Log permission change with IP address
|
||||
audit_logger.logPermissionChange(
|
||||
userId=str(context.user.id),
|
||||
mandateId=targetMandateId,
|
||||
action="user_added_to_mandate",
|
||||
targetUserId=data.targetUserId,
|
||||
details=f"Roles assigned: {data.roleIds}",
|
||||
resourceType="UserMandate",
|
||||
resourceId=str(userMandate.id)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} "
|
||||
f"with roles {data.roleIds}"
|
||||
)
|
||||
|
||||
return UserMandateResponse(
|
||||
id=str(userMandate.id), # UserMandate ID as primary key
|
||||
userId=data.targetUserId,
|
||||
mandateId=targetMandateId,
|
||||
roleIds=data.roleIds,
|
||||
enabled=True
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding user to mandate: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add user to mandate: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{targetMandateId}/users/{targetUserId}", response_model=Dict[str, str])
|
||||
@limiter.limit("30/minute")
|
||||
async def remove_user_from_mandate(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||
targetUserId: str = Path(..., description="ID of the user to remove"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Remove a user from a mandate.
|
||||
|
||||
Requires Mandate-Admin role.
|
||||
Cannot remove the last admin from a mandate (orphan prevention).
|
||||
|
||||
Args:
|
||||
targetMandateId: Target mandate ID
|
||||
targetUserId: User ID to remove
|
||||
"""
|
||||
# Check Mandate-Admin permission
|
||||
if not _hasMandateAdminRole(context, targetMandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Verify mandate exists
|
||||
mandate = rootInterface.getMandate(targetMandateId)
|
||||
if not mandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Mandate {targetMandateId} not found"
|
||||
)
|
||||
|
||||
# Get user's membership
|
||||
membership = rootInterface.getUserMandate(targetUserId, targetMandateId)
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {targetUserId} is not a member of this mandate"
|
||||
)
|
||||
|
||||
# Check if this is the last admin (orphan prevention)
|
||||
if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove the last admin from a mandate. Assign another admin first."
|
||||
)
|
||||
|
||||
# Delete UserMandate (CASCADE will delete UserMandateRole entries)
|
||||
rootInterface.deleteUserMandate(targetUserId, targetMandateId)
|
||||
|
||||
# Audit - Log permission change
|
||||
audit_logger.logPermissionChange(
|
||||
userId=str(context.user.id),
|
||||
mandateId=targetMandateId,
|
||||
action="user_removed_from_mandate",
|
||||
targetUserId=targetUserId,
|
||||
details="User removed from mandate",
|
||||
resourceType="UserMandate"
|
||||
)
|
||||
|
||||
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
|
||||
|
||||
return {"message": "User removed from mandate", "userId": targetUserId}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing user from mandate: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to remove user from mandate: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{targetMandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def update_user_roles_in_mandate(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||
targetUserId: str = Path(..., description="ID of the user"),
|
||||
roleIds: List[str] = Body(..., description="New role IDs to assign"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> UserMandateResponse:
|
||||
"""
|
||||
Update a user's roles within a mandate.
|
||||
|
||||
Replaces all existing roles with the new set.
|
||||
Requires Mandate-Admin role.
|
||||
|
||||
Args:
|
||||
targetMandateId: Target mandate ID
|
||||
targetUserId: User ID to update
|
||||
roleIds: New set of role IDs
|
||||
"""
|
||||
# Check Mandate-Admin permission
|
||||
if not _hasMandateAdminRole(context, targetMandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
# Get user's membership
|
||||
membership = rootInterface.getUserMandate(targetUserId, targetMandateId)
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User {targetUserId} is not a member of this mandate"
|
||||
)
|
||||
|
||||
# Validate new roles
|
||||
for roleId in roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if not roleRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role {roleId} not found"
|
||||
)
|
||||
role = roleRecords[0]
|
||||
roleMandateId = role.get("mandateId")
|
||||
if roleMandateId and str(roleMandateId) != str(targetMandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role {roleId} belongs to a different mandate"
|
||||
)
|
||||
|
||||
# Check if removing admin role would leave mandate without admins
|
||||
currentRoleIds = rootInterface.getRoleIdsForUserMandate(str(membership.id))
|
||||
isCurrentlyAdmin = _hasAdminRoleInList(rootInterface, currentRoleIds, targetMandateId)
|
||||
willBeAdmin = _hasAdminRoleInList(rootInterface, roleIds, targetMandateId)
|
||||
|
||||
if isCurrentlyAdmin and not willBeAdmin:
|
||||
if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove admin role from the last admin. Assign another admin first."
|
||||
)
|
||||
|
||||
# Remove existing role assignments
|
||||
existingRoles = rootInterface.db.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": str(membership.id)}
|
||||
)
|
||||
for er in existingRoles:
|
||||
rootInterface.db.recordDelete(UserMandateRole, er.get("id"))
|
||||
|
||||
# Add new role assignments
|
||||
for roleId in roleIds:
|
||||
rootInterface.addRoleToUserMandate(str(membership.id), roleId)
|
||||
|
||||
# Audit - Log role assignment change
|
||||
audit_logger.logPermissionChange(
|
||||
userId=str(context.user.id),
|
||||
mandateId=targetMandateId,
|
||||
action="role_assigned",
|
||||
targetUserId=targetUserId,
|
||||
details=f"New roles: {roleIds}",
|
||||
resourceType="UserMandateRole",
|
||||
resourceId=str(membership.id)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {context.user.id} updated roles for user {targetUserId} "
|
||||
f"in mandate {targetMandateId} to {roleIds}"
|
||||
)
|
||||
|
||||
return UserMandateResponse(
|
||||
id=str(membership.id), # UserMandate ID as primary key
|
||||
userId=targetUserId,
|
||||
mandateId=targetMandateId,
|
||||
roleIds=roleIds,
|
||||
enabled=membership.enabled
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user roles in mandate: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update user roles: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
|
||||
"""
|
||||
Check if the user has mandate admin role for the specified mandate.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
return True
|
||||
|
||||
# Must be in the same mandate context
|
||||
if str(context.mandateId) != str(mandateId):
|
||||
return False
|
||||
|
||||
if not context.roleIds:
|
||||
return False
|
||||
|
||||
try:
|
||||
rootInterface = interfaceDbApp.getRootInterface()
|
||||
|
||||
for roleId in context.roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
roleLabel = role.get("roleLabel", "")
|
||||
# Admin role at mandate level (not feature-instance level)
|
||||
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking mandate admin role: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:
|
||||
"""
|
||||
Check if excluding this user would leave the mandate without any admins.
|
||||
"""
|
||||
try:
|
||||
# Get all UserMandates for this mandate
|
||||
userMandates = interface.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"mandateId": mandateId, "enabled": True}
|
||||
)
|
||||
|
||||
adminCount = 0
|
||||
for um in userMandates:
|
||||
if str(um.get("userId")) == str(excludeUserId):
|
||||
continue
|
||||
|
||||
# Check if this user has admin role
|
||||
roleIds = interface.getRoleIdsForUserMandate(um.get("id"))
|
||||
if _hasAdminRoleInList(interface, roleIds, mandateId):
|
||||
adminCount += 1
|
||||
|
||||
return adminCount == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking last admin: {e}")
|
||||
return True # Fail-safe: assume they're the last admin
|
||||
|
||||
|
||||
def _hasAdminRoleInList(interface, roleIds: List[str], mandateId: str) -> bool:
|
||||
"""
|
||||
Check if any of the role IDs is an admin role for the mandate.
|
||||
"""
|
||||
for roleId in roleIds:
|
||||
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
roleLabel = role.get("roleLabel", "")
|
||||
roleMandateId = role.get("mandateId")
|
||||
# Admin role at mandate level
|
||||
if roleLabel == "admin" and (not roleMandateId or str(roleMandateId) == str(mandateId)):
|
||||
if not role.get("featureInstanceId"):
|
||||
return True
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import json
|
|||
from modules.auth import limiter, getCurrentUser
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||
from modules.datamodels.datamodelUtils import Prompt
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
|
|
@ -58,7 +58,7 @@ async def get_prompts(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult
|
||||
|
|
@ -89,7 +89,7 @@ async def create_prompt(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Prompt:
|
||||
"""Create a new prompt"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Create prompt
|
||||
newPrompt = managementInterface.createPrompt(prompt)
|
||||
|
|
@ -104,7 +104,7 @@ async def get_prompt(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Prompt:
|
||||
"""Get a specific prompt"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Get prompt
|
||||
prompt = managementInterface.getPrompt(promptId)
|
||||
|
|
@ -125,7 +125,7 @@ async def update_prompt(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Prompt:
|
||||
"""Update an existing prompt"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Check if the prompt exists
|
||||
existingPrompt = managementInterface.getPrompt(promptId)
|
||||
|
|
@ -160,7 +160,7 @@ async def delete_prompt(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a prompt"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
# Check if the prompt exists
|
||||
existingPrompt = managementInterface.getPrompt(promptId)
|
||||
|
|
|
|||
|
|
@ -1,849 +0,0 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Routes for Trustee feature data management.
|
||||
Implements CRUD operations for organisations, roles, access, contracts, documents, and positions.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
import io
|
||||
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.interfaces.interfaceDbTrusteeObjects import getInterface
|
||||
from modules.datamodels.datamodelTrustee import (
|
||||
TrusteeOrganisation,
|
||||
TrusteeRole,
|
||||
TrusteeAccess,
|
||||
TrusteeContract,
|
||||
TrusteeDocument,
|
||||
TrusteePosition,
|
||||
TrusteePositionDocument,
|
||||
)
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelPagination import (
|
||||
PaginationParams,
|
||||
PaginatedResponse,
|
||||
PaginationMetadata,
|
||||
normalize_pagination_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/trustee",
|
||||
tags=["Trustee"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# ===== Helper Functions =====
|
||||
|
||||
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
|
||||
"""Parse pagination parameter from JSON string."""
|
||||
if not pagination:
|
||||
return None
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
return PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ===== Organisation Routes =====
|
||||
|
||||
@router.get("/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
|
||||
@limiter.limit("30/minute")
|
||||
async def getOrganisations(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteeOrganisation]:
|
||||
"""Get all organisations with optional pagination."""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"getOrganisations called for user {currentUser.id}, roles: {currentUser.roleLabels}")
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllOrganisations(paginationParams)
|
||||
logger.debug(f"getOrganisations returned {len(result.items)} items")
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/organisations/{orgId}", response_model=TrusteeOrganisation)
|
||||
@limiter.limit("30/minute")
|
||||
async def getOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(..., description="Organisation ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeOrganisation:
|
||||
"""Get a single organisation by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
org = interface.getOrganisation(orgId)
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
||||
return TrusteeOrganisation(**org)
|
||||
|
||||
|
||||
@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createOrganisation(
|
||||
request: Request,
|
||||
data: TrusteeOrganisation = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeOrganisation:
|
||||
"""Create a new organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createOrganisation(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create organisation")
|
||||
return TrusteeOrganisation(**result)
|
||||
|
||||
|
||||
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
|
||||
@limiter.limit("10/minute")
|
||||
async def updateOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(..., description="Organisation ID"),
|
||||
data: TrusteeOrganisation = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeOrganisation:
|
||||
"""Update an organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getOrganisation(orgId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
||||
|
||||
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update organisation")
|
||||
return TrusteeOrganisation(**result)
|
||||
|
||||
|
||||
@router.delete("/organisations/{orgId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deleteOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(..., description="Organisation ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete an organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getOrganisation(orgId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
||||
|
||||
success = interface.deleteOrganisation(orgId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete organisation")
|
||||
return {"message": f"Organisation {orgId} deleted"}
|
||||
|
||||
|
||||
# ===== Role Routes =====
|
||||
|
||||
@router.get("/roles", response_model=PaginatedResponse[TrusteeRole])
|
||||
@limiter.limit("30/minute")
|
||||
async def getRoles(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteeRole]:
|
||||
"""Get all roles with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllRoles(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/roles/{roleId}", response_model=TrusteeRole)
|
||||
@limiter.limit("30/minute")
|
||||
async def getRole(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeRole:
|
||||
"""Get a single role by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
role = interface.getRole(roleId)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||
return TrusteeRole(**role)
|
||||
|
||||
|
||||
@router.post("/roles", response_model=TrusteeRole, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createRole(
|
||||
request: Request,
|
||||
data: TrusteeRole = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeRole:
|
||||
"""Create a new role (sysadmin only)."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createRole(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create role")
|
||||
return TrusteeRole(**result)
|
||||
|
||||
|
||||
@router.put("/roles/{roleId}", response_model=TrusteeRole)
|
||||
@limiter.limit("10/minute")
|
||||
async def updateRole(
|
||||
request: Request,
|
||||
roleId: str = Path(...),
|
||||
data: TrusteeRole = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeRole:
|
||||
"""Update a role (sysadmin only)."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getRole(roleId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||
|
||||
result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update role")
|
||||
return TrusteeRole(**result)
|
||||
|
||||
|
||||
@router.delete("/roles/{roleId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deleteRole(
|
||||
request: Request,
|
||||
roleId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a role (sysadmin only, fails if in use)."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getRole(roleId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||
|
||||
success = interface.deleteRole(roleId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)")
|
||||
return {"message": f"Role {roleId} deleted"}
|
||||
|
||||
|
||||
# ===== Access Routes =====
|
||||
|
||||
@router.get("/access", response_model=PaginatedResponse[TrusteeAccess])
|
||||
@limiter.limit("30/minute")
|
||||
async def getAllAccess(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteeAccess]:
|
||||
"""Get all access records with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllAccess(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/access/{accessId}", response_model=TrusteeAccess)
|
||||
@limiter.limit("30/minute")
|
||||
async def getAccess(
|
||||
request: Request,
|
||||
accessId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeAccess:
|
||||
"""Get a single access record by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
access = interface.getAccess(accessId)
|
||||
if not access:
|
||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
||||
return TrusteeAccess(**access)
|
||||
|
||||
|
||||
@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess])
|
||||
@limiter.limit("30/minute")
|
||||
async def getAccessByOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteeAccess]:
|
||||
"""Get all access records for an organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)]
|
||||
|
||||
|
||||
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
|
||||
@limiter.limit("30/minute")
|
||||
async def getAccessByUser(
|
||||
request: Request,
|
||||
userId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteeAccess]:
|
||||
"""Get all access records for a user."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)]
|
||||
|
||||
|
||||
@router.post("/access", response_model=TrusteeAccess, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createAccess(
|
||||
request: Request,
|
||||
data: TrusteeAccess = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeAccess:
|
||||
"""Create a new access record."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createAccess(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create access")
|
||||
return TrusteeAccess(**result)
|
||||
|
||||
|
||||
@router.put("/access/{accessId}", response_model=TrusteeAccess)
|
||||
@limiter.limit("10/minute")
|
||||
async def updateAccess(
|
||||
request: Request,
|
||||
accessId: str = Path(...),
|
||||
data: TrusteeAccess = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeAccess:
|
||||
"""Update an access record."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getAccess(accessId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
||||
|
||||
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update access")
|
||||
return TrusteeAccess(**result)
|
||||
|
||||
|
||||
@router.delete("/access/{accessId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deleteAccess(
|
||||
request: Request,
|
||||
accessId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete an access record."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getAccess(accessId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
||||
|
||||
success = interface.deleteAccess(accessId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete access")
|
||||
return {"message": f"Access {accessId} deleted"}
|
||||
|
||||
|
||||
# ===== Contract Routes =====
|
||||
|
||||
@router.get("/contracts", response_model=PaginatedResponse[TrusteeContract])
|
||||
@limiter.limit("30/minute")
|
||||
async def getContracts(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteeContract]:
|
||||
"""Get all contracts with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllContracts(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/contracts/{contractId}", response_model=TrusteeContract)
|
||||
@limiter.limit("30/minute")
|
||||
async def getContract(
|
||||
request: Request,
|
||||
contractId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeContract:
|
||||
"""Get a single contract by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
contract = interface.getContract(contractId)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
||||
return TrusteeContract(**contract)
|
||||
|
||||
|
||||
@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
|
||||
@limiter.limit("30/minute")
|
||||
async def getContractsByOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteeContract]:
|
||||
"""Get all contracts for an organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)]
|
||||
|
||||
|
||||
@router.post("/contracts", response_model=TrusteeContract, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createContract(
|
||||
request: Request,
|
||||
data: TrusteeContract = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeContract:
|
||||
"""Create a new contract."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createContract(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create contract")
|
||||
return TrusteeContract(**result)
|
||||
|
||||
|
||||
@router.put("/contracts/{contractId}", response_model=TrusteeContract)
|
||||
@limiter.limit("10/minute")
|
||||
async def updateContract(
|
||||
request: Request,
|
||||
contractId: str = Path(...),
|
||||
data: TrusteeContract = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeContract:
|
||||
"""Update a contract (organisationId is immutable)."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getContract(contractId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
||||
|
||||
result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
|
||||
return TrusteeContract(**result)
|
||||
|
||||
|
||||
@router.delete("/contracts/{contractId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deleteContract(
|
||||
request: Request,
|
||||
contractId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a contract."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getContract(contractId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
||||
|
||||
success = interface.deleteContract(contractId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete contract")
|
||||
return {"message": f"Contract {contractId} deleted"}
|
||||
|
||||
|
||||
# ===== Document Routes =====
|
||||
|
||||
@router.get("/documents", response_model=PaginatedResponse[TrusteeDocument])
|
||||
@limiter.limit("30/minute")
|
||||
async def getDocuments(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteeDocument]:
|
||||
"""Get all documents (metadata only) with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllDocuments(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/documents/{documentId}", response_model=TrusteeDocument)
|
||||
@limiter.limit("30/minute")
|
||||
async def getDocument(
|
||||
request: Request,
|
||||
documentId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeDocument:
|
||||
"""Get document metadata by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
doc = interface.getDocument(documentId)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||
return TrusteeDocument(**doc)
|
||||
|
||||
|
||||
@router.get("/documents/{documentId}/data")
|
||||
@limiter.limit("10/minute")
|
||||
async def getDocumentData(
|
||||
request: Request,
|
||||
documentId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Download document binary data."""
|
||||
interface = getInterface(currentUser)
|
||||
doc = interface.getDocument(documentId)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||
|
||||
data = interface.getDocumentData(documentId)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="Document data not found")
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type=doc.get("documentMimeType", "application/octet-stream"),
|
||||
headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/documents/contract/{contractId}", response_model=List[TrusteeDocument])
|
||||
@limiter.limit("30/minute")
|
||||
async def getDocumentsByContract(
|
||||
request: Request,
|
||||
contractId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteeDocument]:
|
||||
"""Get all documents for a contract."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)]
|
||||
|
||||
|
||||
@router.post("/documents", response_model=TrusteeDocument, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createDocument(
|
||||
request: Request,
|
||||
data: TrusteeDocument = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeDocument:
|
||||
"""Create a new document."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createDocument(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create document")
|
||||
return TrusteeDocument(**result)
|
||||
|
||||
|
||||
@router.put("/documents/{documentId}", response_model=TrusteeDocument)
|
||||
@limiter.limit("10/minute")
|
||||
async def updateDocument(
|
||||
request: Request,
|
||||
documentId: str = Path(...),
|
||||
data: TrusteeDocument = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteeDocument:
|
||||
"""Update document metadata."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getDocument(documentId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||
|
||||
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update document")
|
||||
return TrusteeDocument(**result)
|
||||
|
||||
|
||||
@router.delete("/documents/{documentId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deleteDocument(
|
||||
request: Request,
|
||||
documentId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a document."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getDocument(documentId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||
|
||||
success = interface.deleteDocument(documentId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete document")
|
||||
return {"message": f"Document {documentId} deleted"}
|
||||
|
||||
|
||||
# ===== Position Routes =====
|
||||
|
||||
@router.get("/positions", response_model=PaginatedResponse[TrusteePosition])
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositions(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteePosition]:
|
||||
"""Get all positions with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllPositions(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/positions/{positionId}", response_model=TrusteePosition)
|
||||
@limiter.limit("30/minute")
|
||||
async def getPosition(
|
||||
request: Request,
|
||||
positionId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteePosition:
|
||||
"""Get a single position by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
position = interface.getPosition(positionId)
|
||||
if not position:
|
||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
||||
return TrusteePosition(**position)
|
||||
|
||||
|
||||
@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition])
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositionsByContract(
|
||||
request: Request,
|
||||
contractId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteePosition]:
|
||||
"""Get all positions for a contract."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)]
|
||||
|
||||
|
||||
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositionsByOrganisation(
|
||||
request: Request,
|
||||
orgId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteePosition]:
|
||||
"""Get all positions for an organisation."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)]
|
||||
|
||||
|
||||
@router.post("/positions", response_model=TrusteePosition, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createPosition(
|
||||
request: Request,
|
||||
data: TrusteePosition = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteePosition:
|
||||
"""Create a new position."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createPosition(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create position")
|
||||
return TrusteePosition(**result)
|
||||
|
||||
|
||||
@router.put("/positions/{positionId}", response_model=TrusteePosition)
|
||||
@limiter.limit("10/minute")
|
||||
async def updatePosition(
|
||||
request: Request,
|
||||
positionId: str = Path(...),
|
||||
data: TrusteePosition = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteePosition:
|
||||
"""Update a position."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getPosition(positionId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
||||
|
||||
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to update position")
|
||||
return TrusteePosition(**result)
|
||||
|
||||
|
||||
@router.delete("/positions/{positionId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deletePosition(
|
||||
request: Request,
|
||||
positionId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a position."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getPosition(positionId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
||||
|
||||
success = interface.deletePosition(positionId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete position")
|
||||
return {"message": f"Position {positionId} deleted"}
|
||||
|
||||
|
||||
# ===== Position-Document Link Routes =====
|
||||
|
||||
@router.get("/position-documents", response_model=PaginatedResponse[TrusteePositionDocument])
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositionDocuments(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> PaginatedResponse[TrusteePositionDocument]:
|
||||
"""Get all position-document links with optional pagination."""
|
||||
paginationParams = _parsePagination(pagination)
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.getAllPositionDocuments(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page or 1,
|
||||
pageSize=paginationParams.pageSize or 20,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort if paginationParams else [],
|
||||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/position-documents/{linkId}", response_model=TrusteePositionDocument)
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositionDocument(
|
||||
request: Request,
|
||||
linkId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteePositionDocument:
|
||||
"""Get a single position-document link by ID."""
|
||||
interface = getInterface(currentUser)
|
||||
link = interface.getPositionDocument(linkId)
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
||||
return TrusteePositionDocument(**link)
|
||||
|
||||
|
||||
@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
|
||||
@limiter.limit("30/minute")
|
||||
async def getDocumentsForPosition(
|
||||
request: Request,
|
||||
positionId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteePositionDocument]:
|
||||
"""Get all document links for a position."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)]
|
||||
|
||||
|
||||
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
|
||||
@limiter.limit("30/minute")
|
||||
async def getPositionsForDocument(
|
||||
request: Request,
|
||||
documentId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[TrusteePositionDocument]:
|
||||
"""Get all position links for a document."""
|
||||
interface = getInterface(currentUser)
|
||||
return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)]
|
||||
|
||||
|
||||
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
|
||||
@limiter.limit("10/minute")
|
||||
async def createPositionDocument(
|
||||
request: Request,
|
||||
data: TrusteePositionDocument = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> TrusteePositionDocument:
|
||||
"""Create a new position-document link."""
|
||||
interface = getInterface(currentUser)
|
||||
result = interface.createPositionDocument(data.model_dump())
|
||||
if not result:
|
||||
raise HTTPException(status_code=400, detail="Failed to create link")
|
||||
return TrusteePositionDocument(**result)
|
||||
|
||||
|
||||
@router.delete("/position-documents/{linkId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def deletePositionDocument(
|
||||
request: Request,
|
||||
linkId: str = Path(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a position-document link."""
|
||||
interface = getInterface(currentUser)
|
||||
existing = interface.getPositionDocument(linkId)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
||||
|
||||
success = interface.deletePositionDocument(linkId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to delete link")
|
||||
return {"message": f"Link {linkId} deleted"}
|
||||
|
|
@ -3,48 +3,207 @@
|
|||
"""
|
||||
User routes for the backend API.
|
||||
Implements the endpoints for user management.
|
||||
|
||||
MULTI-TENANT: User management requires RequestContext.
|
||||
- mandateId from X-Mandate-Id header determines which users are visible
|
||||
- SysAdmin can see all users across mandates
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Import interfaces and models
|
||||
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
|
||||
# Import the attribute definition and helper functions
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelUam import User, UserInDB
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Apply filters and sorting to a list of items.
|
||||
This is used when we can't do server-side filtering in the database (e.g., SysAdmin view).
|
||||
|
||||
Args:
|
||||
items: List of dictionaries to filter/sort
|
||||
paginationParams: Pagination parameters with filters and sort
|
||||
|
||||
Returns:
|
||||
Filtered and sorted list
|
||||
"""
|
||||
if not paginationParams:
|
||||
return items
|
||||
|
||||
result = items.copy()
|
||||
|
||||
# Apply filters
|
||||
if paginationParams.filters:
|
||||
filters = paginationParams.filters
|
||||
|
||||
# Handle general search
|
||||
searchTerm = filters.get('search', '').lower() if filters.get('search') else None
|
||||
|
||||
if searchTerm:
|
||||
def matchesSearch(item: Dict[str, Any]) -> bool:
|
||||
for value in item.values():
|
||||
if value is not None and searchTerm in str(value).lower():
|
||||
return True
|
||||
return False
|
||||
result = [item for item in result if matchesSearch(item)]
|
||||
|
||||
# Handle field-specific filters
|
||||
for field, filterValue in filters.items():
|
||||
if field == 'search':
|
||||
continue # Already handled
|
||||
|
||||
if isinstance(filterValue, dict) and 'operator' in filterValue:
|
||||
operator = filterValue.get('operator', 'equals')
|
||||
value = filterValue.get('value')
|
||||
else:
|
||||
operator = 'equals'
|
||||
value = filterValue
|
||||
|
||||
if value is None or value == '':
|
||||
continue
|
||||
|
||||
def matchesFilter(item: Dict[str, Any], f: str, op: str, v: Any) -> bool:
|
||||
itemValue = item.get(f)
|
||||
if itemValue is None:
|
||||
return False
|
||||
|
||||
# Convert to string for comparison if needed
|
||||
itemStr = str(itemValue).lower()
|
||||
valueStr = str(v).lower()
|
||||
|
||||
if op in ('equals', 'eq'):
|
||||
return itemStr == valueStr
|
||||
elif op == 'contains':
|
||||
return valueStr in itemStr
|
||||
elif op == 'startsWith':
|
||||
return itemStr.startswith(valueStr)
|
||||
elif op == 'endsWith':
|
||||
return itemStr.endswith(valueStr)
|
||||
elif op in ('gt', 'gte', 'lt', 'lte'):
|
||||
try:
|
||||
itemNum = float(itemValue)
|
||||
valueNum = float(v)
|
||||
if op == 'gt':
|
||||
return itemNum > valueNum
|
||||
elif op == 'gte':
|
||||
return itemNum >= valueNum
|
||||
elif op == 'lt':
|
||||
return itemNum < valueNum
|
||||
elif op == 'lte':
|
||||
return itemNum <= valueNum
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
elif op == 'in':
|
||||
if isinstance(v, list):
|
||||
return itemStr in [str(x).lower() for x in v]
|
||||
return False
|
||||
elif op == 'notIn':
|
||||
if isinstance(v, list):
|
||||
return itemStr not in [str(x).lower() for x in v]
|
||||
return True
|
||||
return True
|
||||
|
||||
result = [item for item in result if matchesFilter(item, field, operator, value)]
|
||||
|
||||
# Apply sorting
|
||||
if paginationParams.sort:
|
||||
for sortField in reversed(paginationParams.sort):
|
||||
fieldName = sortField.field
|
||||
ascending = sortField.direction == 'asc'
|
||||
|
||||
def getSortKey(item: Dict[str, Any]):
|
||||
value = item.get(fieldName)
|
||||
if value is None:
|
||||
return (1, '') # Nulls last
|
||||
if isinstance(value, bool):
|
||||
return (0, not value if ascending else value)
|
||||
if isinstance(value, (int, float)):
|
||||
return (0, value)
|
||||
return (0, str(value).lower())
|
||||
|
||||
result = sorted(result, key=getSortKey, reverse=not ascending)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/users",
|
||||
tags=["Manage Users"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONS ENDPOINTS (for dropdowns)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/options", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def get_user_options(
|
||||
request: Request,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get user options for select dropdowns.
|
||||
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
|
||||
Returns standardized format: [{ value, label }]
|
||||
"""
|
||||
try:
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
if context.mandateId:
|
||||
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||
users = result.items if hasattr(result, 'items') else result
|
||||
elif context.isSysAdmin:
|
||||
users = appInterface.getAllUsers()
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return [
|
||||
{"value": user.id, "label": user.fullName or user.username or user.email or user.id}
|
||||
for user in users
|
||||
]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user options: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get user options: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CRUD ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse[User])
|
||||
@limiter.limit("30/minute")
|
||||
async def get_users(
|
||||
request: Request,
|
||||
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[User]:
|
||||
"""
|
||||
Get users with optional pagination, sorting, and filtering.
|
||||
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
|
||||
SysAdmin without mandateId sees all users.
|
||||
|
||||
Query Parameters:
|
||||
- mandateId: Optional mandate ID to filter users
|
||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||
|
||||
Examples:
|
||||
- GET /api/users/ (no pagination - returns all users)
|
||||
- GET /api/users/ (no pagination - returns all users in mandate)
|
||||
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
||||
"""
|
||||
try:
|
||||
|
|
@ -62,30 +221,82 @@ async def get_users(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
# If mandateId is provided, use it, otherwise use the current user's mandate
|
||||
targetMandateId = mandateId or currentUser.mandateId
|
||||
# Get users with optional pagination
|
||||
result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams)
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult
|
||||
# If no pagination, result is List[User]
|
||||
if paginationParams:
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters
|
||||
# MULTI-TENANT: Use mandateId from context (header)
|
||||
# SysAdmin without mandateId can see all users
|
||||
if context.mandateId:
|
||||
# Get users for specific mandate using getUsersByMandate
|
||||
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
||||
|
||||
# getUsersByMandate returns PaginatedResult if pagination was provided
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=result.currentPage,
|
||||
pageSize=result.pageSize,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No pagination - result is a list
|
||||
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
|
||||
return PaginatedResponse(
|
||||
items=users,
|
||||
pagination=None
|
||||
)
|
||||
elif context.isSysAdmin:
|
||||
# SysAdmin without mandateId sees all users
|
||||
# Get all users directly from database using UserInDB (the actual database model)
|
||||
allUsers = appInterface.db.getRecordset(UserInDB)
|
||||
# Convert to cleaned dictionaries first for filtering
|
||||
cleanedUsers = []
|
||||
for u in allUsers:
|
||||
cleanedUser = {k: v for k, v in u.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
|
||||
# Ensure roleLabels is always a list
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
cleanedUsers.append(cleanedUser)
|
||||
|
||||
# Apply server-side filtering and sorting
|
||||
filteredUsers = _applyFiltersAndSort(cleanedUsers, paginationParams)
|
||||
|
||||
# Convert to User objects
|
||||
users = [User(**u) for u in filteredUsers]
|
||||
|
||||
if paginationParams:
|
||||
import math
|
||||
totalItems = len(users)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
endIdx = startIdx + paginationParams.pageSize
|
||||
paginatedUsers = users[startIdx:endIdx]
|
||||
|
||||
return PaginatedResponse(
|
||||
items=paginatedUsers,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
)
|
||||
else:
|
||||
return PaginatedResponse(
|
||||
items=users,
|
||||
pagination=None
|
||||
)
|
||||
)
|
||||
else:
|
||||
return PaginatedResponse(
|
||||
items=result,
|
||||
pagination=None
|
||||
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -101,11 +312,14 @@ async def get_users(
|
|||
async def get_user(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="ID of the user"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> User:
|
||||
"""Get a specific user by ID"""
|
||||
"""
|
||||
Get a specific user by ID.
|
||||
MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin.
|
||||
"""
|
||||
try:
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
# Get user without filtering by enabled status
|
||||
user = appInterface.getUser(userId)
|
||||
|
||||
|
|
@ -114,6 +328,19 @@ async def get_user(
|
|||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {userId} not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||
if context.mandateId and not context.isSysAdmin:
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||
"userId": userId,
|
||||
"mandateId": str(context.mandateId)
|
||||
})
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not in your mandate"
|
||||
)
|
||||
|
||||
return user
|
||||
except HTTPException:
|
||||
|
|
@ -125,30 +352,55 @@ async def get_user(
|
|||
detail=f"Failed to get user: {str(e)}"
|
||||
)
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
"""Request body for creating a new user"""
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
fullName: Optional[str] = None
|
||||
language: str = "en"
|
||||
enabled: bool = True
|
||||
isSysAdmin: bool = False
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("", response_model=User)
|
||||
@limiter.limit("10/minute")
|
||||
async def create_user(
|
||||
request: Request,
|
||||
user_data: User = Body(...),
|
||||
password: Optional[str] = Body(None, embed=True),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
userData: CreateUserRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> User:
|
||||
"""Create a new user"""
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
"""
|
||||
Create a new user.
|
||||
MULTI-TENANT: User is created and automatically added to the current mandate.
|
||||
"""
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Extract fields from User model and call createUser with individual parameters
|
||||
from modules.datamodels.datamodelUam import AuthAuthority
|
||||
# Extract fields from request model and call createUser with individual parameters
|
||||
newUser = appInterface.createUser(
|
||||
username=user_data.username,
|
||||
password=password,
|
||||
email=user_data.email,
|
||||
fullName=user_data.fullName,
|
||||
language=user_data.language,
|
||||
enabled=user_data.enabled,
|
||||
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"],
|
||||
authenticationAuthority=user_data.authenticationAuthority
|
||||
username=userData.username,
|
||||
password=userData.password,
|
||||
email=userData.email,
|
||||
fullName=userData.fullName,
|
||||
language=userData.language,
|
||||
enabled=userData.enabled,
|
||||
authenticationAuthority=AuthAuthority.LOCAL,
|
||||
isSysAdmin=userData.isSysAdmin
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
||||
if context.mandateId:
|
||||
# Get "user" role ID
|
||||
userRole = appInterface.getRoleByLabel("user")
|
||||
roleIds = [str(userRole.id)] if userRole else []
|
||||
|
||||
appInterface.createUserMandate(
|
||||
userId=str(newUser.id),
|
||||
mandateId=str(context.mandateId),
|
||||
roleIds=roleIds
|
||||
)
|
||||
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
||||
|
||||
return newUser
|
||||
|
||||
@router.put("/{userId}", response_model=User)
|
||||
|
|
@ -157,10 +409,13 @@ async def update_user(
|
|||
request: Request,
|
||||
userId: str = Path(..., description="ID of the user to update"),
|
||||
userData: User = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> User:
|
||||
"""Update an existing user"""
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
"""
|
||||
Update an existing user.
|
||||
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
|
||||
"""
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Check if the user exists
|
||||
existingUser = appInterface.getUser(userId)
|
||||
|
|
@ -170,6 +425,19 @@ async def update_user(
|
|||
detail=f"User with ID {userId} not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||
if context.mandateId and not context.isSysAdmin:
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||
"userId": userId,
|
||||
"mandateId": str(context.mandateId)
|
||||
})
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot update user outside your mandate"
|
||||
)
|
||||
|
||||
# Update user
|
||||
updatedUser = appInterface.updateUser(userId, userData)
|
||||
|
||||
|
|
@ -187,28 +455,44 @@ async def reset_user_password(
|
|||
request: Request,
|
||||
userId: str = Path(..., description="ID of the user to reset password for"),
|
||||
newPassword: str = Body(..., embed=True),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Reset user password (Admin only)"""
|
||||
"""
|
||||
Reset user password (Admin only).
|
||||
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
|
||||
"""
|
||||
try:
|
||||
# Check if current user is admin
|
||||
if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []):
|
||||
if not context.isSysAdmin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only administrators can reset passwords"
|
||||
)
|
||||
|
||||
# Get user interface
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Get target user
|
||||
target_user = appInterface.getUserById(userId)
|
||||
target_user = appInterface.getUser(userId)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||
if context.mandateId and not context.isSysAdmin:
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||
"userId": userId,
|
||||
"mandateId": str(context.mandateId)
|
||||
})
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot reset password for user outside your mandate"
|
||||
)
|
||||
|
||||
# Validate password strength
|
||||
if len(newPassword) < 8:
|
||||
raise HTTPException(
|
||||
|
|
@ -226,12 +510,11 @@ async def reset_user_password(
|
|||
|
||||
# SECURITY: Automatically revoke all tokens for the user after password reset
|
||||
try:
|
||||
from modules.datamodels.datamodelUam import AuthAuthority
|
||||
revoked_count = appInterface.revokeTokensByUser(
|
||||
userId=userId,
|
||||
authority=None, # Revoke all authorities
|
||||
mandateId=None, # Revoke across all mandates
|
||||
revokedBy=currentUser.id,
|
||||
revokedBy=context.user.id,
|
||||
reason="password_reset"
|
||||
)
|
||||
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
|
||||
|
|
@ -243,10 +526,12 @@ async def reset_user_password(
|
|||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
userId=str(context.user.id),
|
||||
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||
action="password_reset",
|
||||
details=f"Reset password for user {userId}"
|
||||
details=f"Reset password for user {userId}",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -271,15 +556,18 @@ async def change_password(
|
|||
request: Request,
|
||||
currentPassword: str = Body(..., embed=True),
|
||||
newPassword: str = Body(..., embed=True),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Change current user's password"""
|
||||
"""
|
||||
Change current user's password.
|
||||
MULTI-TENANT: User changes their own password (no mandate restriction).
|
||||
"""
|
||||
try:
|
||||
# Get user interface
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Verify current password
|
||||
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash):
|
||||
if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect"
|
||||
|
|
@ -293,7 +581,7 @@ async def change_password(
|
|||
)
|
||||
|
||||
# Change password
|
||||
success = appInterface.resetUserPassword(str(currentUser.id), newPassword)
|
||||
success = appInterface.resetUserPassword(str(context.user.id), newPassword)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -302,27 +590,28 @@ async def change_password(
|
|||
|
||||
# SECURITY: Automatically revoke all tokens for the user after password change
|
||||
try:
|
||||
from modules.datamodels.datamodelUam import AuthAuthority
|
||||
revoked_count = appInterface.revokeTokensByUser(
|
||||
userId=str(currentUser.id),
|
||||
userId=str(context.user.id),
|
||||
authority=None, # Revoke all authorities
|
||||
mandateId=None, # Revoke across all mandates
|
||||
revokedBy=currentUser.id,
|
||||
revokedBy=context.user.id,
|
||||
reason="password_change"
|
||||
)
|
||||
logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change")
|
||||
logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}")
|
||||
logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}")
|
||||
# Don't fail the password change if token revocation fails
|
||||
|
||||
# Log password change
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
userId=str(context.user.id),
|
||||
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||
action="password_change",
|
||||
details="User changed their own password"
|
||||
details="User changed their own password",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -342,13 +631,15 @@ async def change_password(
|
|||
|
||||
@router.post("/{userId}/send-password-link")
|
||||
@limiter.limit("10/minute")
|
||||
async def sendPasswordLink(
|
||||
async def send_password_link(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="ID of the user to send password setup link"),
|
||||
frontendUrl: str = Body(..., embed=True),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Send password setup/reset link to a user (admin function).
|
||||
"""
|
||||
Send password setup/reset link to a user (admin function).
|
||||
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
|
||||
|
||||
This allows admins to send a magic link to users to set or reset their password.
|
||||
Used when creating users without password or to help users who forgot their password.
|
||||
|
|
@ -359,10 +650,9 @@ async def sendPasswordLink(
|
|||
"""
|
||||
try:
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||
|
||||
# Get user interface
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Get target user
|
||||
targetUser = appInterface.getUser(userId)
|
||||
|
|
@ -372,6 +662,19 @@ async def sendPasswordLink(
|
|||
detail="User not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||
if context.mandateId and not context.isSysAdmin:
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||
"userId": userId,
|
||||
"mandateId": str(context.mandateId)
|
||||
})
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot send password link to user outside your mandate"
|
||||
)
|
||||
|
||||
# Check if user has an email
|
||||
if not targetUser.email:
|
||||
raise HTTPException(
|
||||
|
|
@ -440,15 +743,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
|
|||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
userId=str(context.user.id),
|
||||
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||
action="send_password_link",
|
||||
details=f"Sent password setup link to user {userId} ({targetUser.email})"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
|
||||
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}")
|
||||
|
||||
return {
|
||||
"message": f"Password setup link sent to {targetUser.email}",
|
||||
|
|
@ -470,10 +773,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
|
|||
async def delete_user(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="ID of the user to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a user"""
|
||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
||||
"""
|
||||
Delete a user.
|
||||
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
|
||||
"""
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
# Check if the user exists
|
||||
existingUser = appInterface.getUser(userId)
|
||||
|
|
@ -483,6 +789,25 @@ async def delete_user(
|
|||
detail=f"User with ID {userId} not found"
|
||||
)
|
||||
|
||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||
if context.mandateId and not context.isSysAdmin:
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||
"userId": userId,
|
||||
"mandateId": str(context.mandateId)
|
||||
})
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete user outside your mandate"
|
||||
)
|
||||
|
||||
# Delete UserMandate entries for this user first
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
for um in userMandates:
|
||||
appInterface.db.deleteRecord(UserMandate, um["id"])
|
||||
|
||||
success = appInterface.deleteUser(userId)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
|
|||
# Import auth modules
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface
|
||||
# Import interfaces from feature containers
|
||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
||||
from modules.interfaces.interfaceDbChat import getInterface
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
|
||||
# Import models
|
||||
# Import models from feature containers
|
||||
from modules.datamodels.datamodelChat import (
|
||||
ChatWorkflow,
|
||||
ChatMessage,
|
||||
|
|
@ -45,7 +45,7 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
def getServiceChat(currentUser: User):
|
||||
return interfaceDbChatObjects.getInterface(currentUser)
|
||||
return interfaceDbChat.getInterface(currentUser)
|
||||
|
||||
# Consolidated endpoint for getting all workflows
|
||||
@router.get("/", response_model=PaginatedResponse[ChatWorkflow])
|
||||
474
modules/routes/routeGdpr.py
Normal file
474
modules/routes/routeGdpr.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
GDPR compliance routes for the backend API.
|
||||
Implements data subject rights according to GDPR regulations.
|
||||
|
||||
GDPR Articles implemented:
|
||||
- Article 15: Right of access (data export)
|
||||
- Article 16: Right to rectification (via existing update endpoints)
|
||||
- Article 17: Right to erasure (account deletion)
|
||||
- Article 20: Right to data portability (machine-readable export)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.datamodels.datamodelUam import User, UserInDB, Mandate, UserConnection
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/user/me",
|
||||
tags=["GDPR"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Models
|
||||
# =============================================================================
|
||||
|
||||
class DataExportResponse(BaseModel):
|
||||
"""Response model for GDPR data export"""
|
||||
exportedAt: float
|
||||
userId: str
|
||||
userData: Dict[str, Any]
|
||||
mandates: List[Dict[str, Any]]
|
||||
featureAccesses: List[Dict[str, Any]]
|
||||
invitationsCreated: List[Dict[str, Any]]
|
||||
invitationsUsed: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class DataPortabilityResponse(BaseModel):
|
||||
"""Machine-readable data portability response (JSON-LD format)"""
|
||||
context: str = Field(alias="@context")
|
||||
type: str = Field(alias="@type")
|
||||
identifier: str
|
||||
exportDate: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class DeletionResult(BaseModel):
|
||||
"""Result of account deletion"""
|
||||
success: bool
|
||||
userId: str
|
||||
deletedAt: float
|
||||
deletedData: List[str]
|
||||
message: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Article 15: Right of Access
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/data-export", response_model=DataExportResponse)
|
||||
@limiter.limit("5/minute")
|
||||
async def export_user_data(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> DataExportResponse:
|
||||
"""
|
||||
Export all personal data (GDPR Article 15).
|
||||
|
||||
Returns all data associated with the authenticated user including:
|
||||
- User profile data
|
||||
- Mandate memberships
|
||||
- Feature access records
|
||||
- Invitations created and used
|
||||
|
||||
Note: This exports Gateway-level data only. Feature-specific data
|
||||
(e.g., chat workflows, trustee contracts) should be exported via
|
||||
feature-specific endpoints.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# User data (exclude sensitive fields)
|
||||
userData = {
|
||||
"id": str(currentUser.id),
|
||||
"username": currentUser.username,
|
||||
"email": currentUser.email,
|
||||
"fullName": getattr(currentUser, "fullName", None),
|
||||
"enabled": currentUser.enabled,
|
||||
"isSysAdmin": getattr(currentUser, "isSysAdmin", False),
|
||||
"createdAt": getattr(currentUser, "createdAt", None),
|
||||
"updatedAt": getattr(currentUser, "updatedAt", None),
|
||||
"lastLogin": getattr(currentUser, "lastLogin", None),
|
||||
"language": getattr(currentUser, "language", None),
|
||||
"authenticationAuthority": str(getattr(currentUser, "authenticationAuthority", ""))
|
||||
}
|
||||
|
||||
# Mandate memberships
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandates = rootInterface.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": str(currentUser.id)}
|
||||
)
|
||||
|
||||
mandates = []
|
||||
for um in userMandates:
|
||||
mandateId = um.get("mandateId")
|
||||
|
||||
# Get mandate details
|
||||
mandateRecords = rootInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": mandateId}
|
||||
)
|
||||
mandateName = mandateRecords[0].get("name") if mandateRecords else "Unknown"
|
||||
|
||||
# Get roles for this membership
|
||||
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
|
||||
|
||||
mandates.append({
|
||||
"userMandateId": um.get("id"),
|
||||
"mandateId": mandateId,
|
||||
"mandateName": mandateName,
|
||||
"enabled": um.get("enabled", True),
|
||||
"roleIds": roleIds,
|
||||
"joinedAt": um.get("createdAt")
|
||||
})
|
||||
|
||||
# Feature access records
|
||||
from modules.datamodels.datamodelMembership import FeatureAccess
|
||||
featureAccesses = rootInterface.db.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={"userId": str(currentUser.id)}
|
||||
)
|
||||
|
||||
featureAccessList = []
|
||||
for fa in featureAccesses:
|
||||
instanceId = fa.get("featureInstanceId")
|
||||
|
||||
# Get instance details
|
||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||
instanceRecords = rootInterface.db.getRecordset(
|
||||
FeatureInstance,
|
||||
recordFilter={"id": instanceId}
|
||||
)
|
||||
|
||||
instanceInfo = instanceRecords[0] if instanceRecords else {}
|
||||
roleIds = rootInterface.getRoleIdsForFeatureAccess(fa.get("id"))
|
||||
|
||||
featureAccessList.append({
|
||||
"featureAccessId": fa.get("id"),
|
||||
"featureInstanceId": instanceId,
|
||||
"featureCode": instanceInfo.get("featureCode"),
|
||||
"instanceLabel": instanceInfo.get("label"),
|
||||
"enabled": fa.get("enabled", True),
|
||||
"roleIds": roleIds
|
||||
})
|
||||
|
||||
# Invitations created by user
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
invitationsCreated = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"createdBy": str(currentUser.id)}
|
||||
)
|
||||
|
||||
invitationsCreatedList = [
|
||||
{
|
||||
"id": inv.get("id"),
|
||||
"mandateId": inv.get("mandateId"),
|
||||
"createdAt": inv.get("createdAt"),
|
||||
"expiresAt": inv.get("expiresAt"),
|
||||
"maxUses": inv.get("maxUses"),
|
||||
"currentUses": inv.get("currentUses")
|
||||
}
|
||||
for inv in invitationsCreated
|
||||
]
|
||||
|
||||
# Invitations used by user
|
||||
invitationsUsed = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"usedBy": str(currentUser.id)}
|
||||
)
|
||||
|
||||
invitationsUsedList = [
|
||||
{
|
||||
"id": inv.get("id"),
|
||||
"mandateId": inv.get("mandateId"),
|
||||
"usedAt": inv.get("usedAt")
|
||||
}
|
||||
for inv in invitationsUsed
|
||||
]
|
||||
|
||||
# Audit log - GDPR Article 15 data export
|
||||
audit_logger.logGdprEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
action="gdpr_data_export",
|
||||
details="User requested data export (GDPR Article 15 - Right of Access)",
|
||||
ipAddress=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)")
|
||||
|
||||
return DataExportResponse(
|
||||
exportedAt=getUtcTimestamp(),
|
||||
userId=str(currentUser.id),
|
||||
userData=userData,
|
||||
mandates=mandates,
|
||||
featureAccesses=featureAccessList,
|
||||
invitationsCreated=invitationsCreatedList,
|
||||
invitationsUsed=invitationsUsedList
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting user data: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Article 20: Right to Data Portability
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/data-portability")
|
||||
@limiter.limit("5/minute")
|
||||
async def export_portable_data(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Export data in portable, machine-readable format (GDPR Article 20).
|
||||
|
||||
Returns data in JSON-LD format suitable for transfer to another service.
|
||||
This is a structured format that can be easily parsed by machines.
|
||||
"""
|
||||
try:
|
||||
# Get full export data first
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Build portable data structure
|
||||
portableData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"identifier": str(currentUser.id),
|
||||
"name": getattr(currentUser, "fullName", None) or currentUser.username,
|
||||
"email": currentUser.email,
|
||||
"additionalProperty": []
|
||||
}
|
||||
|
||||
# Add mandate memberships as organization affiliations
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandates = rootInterface.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": str(currentUser.id)}
|
||||
)
|
||||
|
||||
affiliations = []
|
||||
for um in userMandates:
|
||||
mandateRecords = rootInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": um.get("mandateId")}
|
||||
)
|
||||
if mandateRecords:
|
||||
mandate = mandateRecords[0]
|
||||
affiliations.append({
|
||||
"@type": "Organization",
|
||||
"identifier": um.get("mandateId"),
|
||||
"name": mandate.get("name"),
|
||||
"membershipActive": um.get("enabled", True)
|
||||
})
|
||||
|
||||
if affiliations:
|
||||
portableData["affiliation"] = affiliations
|
||||
|
||||
# Wrap in export envelope
|
||||
exportEnvelope = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DataDownload",
|
||||
"identifier": f"export-{currentUser.id}-{int(getUtcTimestamp())}",
|
||||
"dateCreated": _timestampToIso(getUtcTimestamp()),
|
||||
"encodingFormat": "application/ld+json",
|
||||
"about": portableData
|
||||
}
|
||||
|
||||
# Audit log - GDPR Article 20 data portability
|
||||
audit_logger.logGdprEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
action="gdpr_data_portability",
|
||||
details="User requested portable data export (GDPR Article 20 - Right to Data Portability)",
|
||||
ipAddress=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)")
|
||||
|
||||
return JSONResponse(
|
||||
content=exportEnvelope,
|
||||
media_type="application/ld+json"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting portable data: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Article 17: Right to Erasure
|
||||
# =============================================================================
|
||||
|
||||
@router.delete("/", response_model=DeletionResult)
|
||||
@limiter.limit("1/hour")
|
||||
async def delete_account(
|
||||
request: Request,
|
||||
confirmDeletion: bool = False,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> DeletionResult:
|
||||
"""
|
||||
Delete own account and all associated data (GDPR Article 17).
|
||||
|
||||
IMPORTANT: This action is irreversible!
|
||||
- All user data will be permanently deleted
|
||||
- All mandate memberships will be removed
|
||||
- All feature accesses will be removed
|
||||
- All created invitations will be revoked
|
||||
|
||||
Args:
|
||||
confirmDeletion: Must be True to confirm deletion
|
||||
"""
|
||||
if not confirmDeletion:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Deletion not confirmed. Set confirmDeletion=true to proceed."
|
||||
)
|
||||
|
||||
# Prevent SysAdmin self-deletion (safety measure)
|
||||
if getattr(currentUser, "isSysAdmin", False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="SysAdmin accounts cannot be self-deleted. Contact another SysAdmin."
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Audit log BEFORE deletion (audit needs userId)
|
||||
audit_logger.logGdprEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
action="gdpr_account_deletion_started",
|
||||
details=f"User initiated account deletion (GDPR Article 17 - Right to Erasure)",
|
||||
ipAddress=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# Step 2: Revoke invitations BEFORE generic deletion (business logic)
|
||||
rootInterface = getRootInterface()
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
userInvitations = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"createdBy": str(currentUser.id)}
|
||||
)
|
||||
|
||||
for inv in userInvitations:
|
||||
rootInterface.db.recordModify(
|
||||
Invitation,
|
||||
inv.get("id"),
|
||||
{"revokedAt": getUtcTimestamp()}
|
||||
)
|
||||
|
||||
logger.info(f"Revoked {len(userInvitations)} invitations for user {currentUser.id}")
|
||||
|
||||
# Step 3: Generic deletion across ALL databases
|
||||
deletionStats = deleteUserDataAcrossAllDatabases(str(currentUser.id), currentUser)
|
||||
|
||||
# Step 4: Delete the user account from UserInDB (authentication table)
|
||||
# This must be done AFTER all other deletions to maintain audit trail
|
||||
deletedAt = getUtcTimestamp()
|
||||
rootInterface.db.recordDelete(UserInDB, str(currentUser.id))
|
||||
|
||||
# Build summary for response
|
||||
deletedData = buildDeletionSummary(deletionStats)
|
||||
deletedData.insert(0, f"Invitations revoked: {len(userInvitations)}")
|
||||
deletedData.append("User account deleted from authentication system")
|
||||
|
||||
logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17). "
|
||||
f"Stats: {deletionStats['totalRecordsDeleted']} deleted, "
|
||||
f"{deletionStats['totalRecordsAnonymized']} anonymized")
|
||||
|
||||
return DeletionResult(
|
||||
success=True,
|
||||
userId=str(currentUser.id),
|
||||
deletedAt=deletedAt,
|
||||
deletedData=deletedData,
|
||||
message="Account and all associated data have been permanently deleted or anonymized."
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting account: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete account: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Consent Information Endpoint
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/consent-info", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def get_consent_info(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about data processing and user rights (GDPR transparency).
|
||||
|
||||
Returns information about:
|
||||
- What data is collected
|
||||
- How data is processed
|
||||
- User rights under GDPR
|
||||
- Contact information for data protection inquiries
|
||||
"""
|
||||
return {
|
||||
"dataCollected": {
|
||||
"profile": "Name, email, username, language preferences",
|
||||
"authentication": "Login timestamps, authentication provider",
|
||||
"memberships": "Mandate and feature access records",
|
||||
"activity": "Audit logs for security-relevant actions"
|
||||
},
|
||||
"dataProcessing": {
|
||||
"purpose": "Providing multi-tenant platform services",
|
||||
"legalBasis": "Contract fulfillment and legitimate interest",
|
||||
"retention": "Data retained while account is active, deleted upon account deletion"
|
||||
},
|
||||
"userRights": {
|
||||
"access": "GET /api/user/me/data-export (Article 15)",
|
||||
"portability": "GET /api/user/me/data-portability (Article 20)",
|
||||
"erasure": "DELETE /api/user/me (Article 17)",
|
||||
"rectification": "PUT /api/local/me (Article 16)"
|
||||
},
|
||||
"contact": {
|
||||
"email": "privacy@example.com",
|
||||
"note": "For data protection inquiries, please contact us with your user ID"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _timestampToIso(timestamp: float) -> str:
|
||||
"""Convert Unix timestamp to ISO 8601 format"""
|
||||
from datetime import datetime, timezone
|
||||
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||
return dt.isoformat()
|
||||
777
modules/routes/routeInvitations.py
Normal file
777
modules/routes/routeInvitations.py
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Invitation routes for the backend API.
|
||||
Implements token-based user invitations for self-service onboarding.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Invitations are mandate-scoped (Mandate Admin creates them)
|
||||
- Tokens are secure, time-limited, and optionally use-limited
|
||||
- Users accept invitations to join mandates/features with predefined roles
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/invitations",
|
||||
tags=["Invitations"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Models
|
||||
# =============================================================================
|
||||
|
||||
class InvitationCreate(BaseModel):
|
||||
"""Request model for creating an invitation"""
|
||||
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
|
||||
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
|
||||
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user")
|
||||
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
|
||||
expiresInHours: int = Field(
|
||||
72,
|
||||
ge=1,
|
||||
le=720, # Max 30 days
|
||||
description="Hours until invitation expires"
|
||||
)
|
||||
maxUses: int = Field(
|
||||
1,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Maximum number of times this invitation can be used"
|
||||
)
|
||||
|
||||
|
||||
class InvitationResponse(BaseModel):
|
||||
"""Response model for invitation"""
|
||||
id: str
|
||||
token: str
|
||||
mandateId: str
|
||||
featureInstanceId: Optional[str]
|
||||
roleIds: List[str]
|
||||
targetUsername: str
|
||||
email: Optional[str]
|
||||
createdBy: str
|
||||
createdAt: float
|
||||
expiresAt: float
|
||||
usedBy: Optional[str]
|
||||
usedAt: Optional[float]
|
||||
revokedAt: Optional[float]
|
||||
maxUses: int
|
||||
currentUses: int
|
||||
inviteUrl: str # Full URL for the invitation
|
||||
emailSent: bool = False # Whether invitation email was sent
|
||||
|
||||
|
||||
class InvitationValidation(BaseModel):
|
||||
"""Response model for invitation validation"""
|
||||
valid: bool
|
||||
reason: Optional[str]
|
||||
mandateId: Optional[str]
|
||||
mandateName: Optional[str] = None
|
||||
featureInstanceId: Optional[str]
|
||||
roleIds: List[str]
|
||||
roleLabels: List[str] = []
|
||||
targetUsername: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Invitation CRUD Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/", response_model=InvitationResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def create_invitation(
|
||||
request: Request,
|
||||
data: InvitationCreate,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> InvitationResponse:
|
||||
"""
|
||||
Create a new invitation for the current mandate.
|
||||
|
||||
Requires Mandate-Admin role. Creates a secure token that can be shared
|
||||
with users to join the mandate with predefined roles.
|
||||
|
||||
Args:
|
||||
data: Invitation creation data
|
||||
"""
|
||||
if not context.mandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to create invitations"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Note: targetUsername does NOT need to exist yet!
|
||||
# The invitation can be for a user who will register later.
|
||||
# When they register with this username (or accept the invitation),
|
||||
# they will get the assigned roles.
|
||||
|
||||
# Validate role IDs exist and belong to this mandate or are global
|
||||
for roleId in data.roleIds:
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if not roleRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role '{roleId}' not found"
|
||||
)
|
||||
role = roleRecords[0]
|
||||
# Role must be global or belong to this mandate
|
||||
roleMandateId = role.get("mandateId")
|
||||
if roleMandateId and str(roleMandateId) != str(context.mandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{roleId}' belongs to a different mandate"
|
||||
)
|
||||
|
||||
# Validate feature instance if provided
|
||||
if data.featureInstanceId:
|
||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||
instanceRecords = rootInterface.db.getRecordset(
|
||||
FeatureInstance,
|
||||
recordFilter={"id": data.featureInstanceId}
|
||||
)
|
||||
if not instanceRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Feature instance '{data.featureInstanceId}' not found"
|
||||
)
|
||||
instance = instanceRecords[0]
|
||||
if str(instance.get("mandateId")) != str(context.mandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Feature instance belongs to a different mandate"
|
||||
)
|
||||
|
||||
# Calculate expiration time
|
||||
currentTime = getUtcTimestamp()
|
||||
expiresAt = currentTime + (data.expiresInHours * 3600)
|
||||
|
||||
# Create invitation
|
||||
invitation = Invitation(
|
||||
mandateId=str(context.mandateId),
|
||||
featureInstanceId=data.featureInstanceId,
|
||||
roleIds=data.roleIds,
|
||||
targetUsername=data.targetUsername,
|
||||
email=data.email,
|
||||
createdBy=str(context.user.id),
|
||||
expiresAt=expiresAt,
|
||||
maxUses=data.maxUses
|
||||
)
|
||||
|
||||
createdRecord = rootInterface.db.recordCreate(Invitation, invitation.model_dump())
|
||||
if not createdRecord:
|
||||
raise ValueError("Failed to create invitation record")
|
||||
|
||||
# Build invite URL
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
|
||||
inviteUrl = f"{frontendUrl}/invite/{invitation.token}"
|
||||
|
||||
# Send email if email address is provided
|
||||
emailSent = False
|
||||
if data.email:
|
||||
try:
|
||||
from modules.connectors.connectorMessagingEmail import ConnectorMessagingEmail
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
|
||||
# Get mandate name for the email
|
||||
mandateRecords = rootInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": str(context.mandateId)}
|
||||
)
|
||||
mandateName = mandateRecords[0].get("name", "PowerOn") if mandateRecords else "PowerOn"
|
||||
|
||||
emailConnector = ConnectorMessagingEmail()
|
||||
emailSubject = f"Einladung zu {mandateName}"
|
||||
emailBody = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h2>Sie wurden eingeladen!</h2>
|
||||
<p>Hallo <strong>{data.targetUsername}</strong>,</p>
|
||||
<p>Sie wurden eingeladen, dem Mandanten <strong>{mandateName}</strong> beizutreten.</p>
|
||||
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
|
||||
Einladung annehmen
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
Oder kopieren Sie diesen Link in Ihren Browser:<br>
|
||||
<code>{inviteUrl}</code>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
Diese Einladung ist {data.expiresInHours} Stunden gültig.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 0.8em;">
|
||||
Diese E-Mail wurde automatisch von PowerOn gesendet.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
emailConnector.send(
|
||||
recipient=data.email,
|
||||
subject=emailSubject,
|
||||
message=emailBody
|
||||
)
|
||||
emailSent = True
|
||||
logger.info(f"Invitation email sent to {data.email} for user {data.targetUsername}")
|
||||
except Exception as emailError:
|
||||
logger.warning(f"Failed to send invitation email to {data.email}: {emailError}")
|
||||
# Don't fail the invitation creation if email fails
|
||||
|
||||
# Update the invitation record with emailSent status
|
||||
if emailSent:
|
||||
rootInterface.db.recordModify(
|
||||
Invitation,
|
||||
createdRecord.get("id"),
|
||||
{"emailSent": True}
|
||||
)
|
||||
createdRecord["emailSent"] = True
|
||||
|
||||
# If the target user already exists, create an in-app notification
|
||||
try:
|
||||
existingUser = rootInterface.getUserByUsername(data.targetUsername)
|
||||
if existingUser:
|
||||
from modules.routes.routeNotifications import createInvitationNotification
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
|
||||
# Get mandate name for notification
|
||||
mandateRecords = rootInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": str(context.mandateId)}
|
||||
)
|
||||
mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn"
|
||||
inviterName = context.user.fullName or context.user.username
|
||||
|
||||
createInvitationNotification(
|
||||
userId=str(existingUser.id),
|
||||
invitationId=str(createdRecord.get("id")),
|
||||
mandateName=mandateName,
|
||||
inviterName=inviterName
|
||||
)
|
||||
logger.info(f"Created notification for existing user {data.targetUsername}")
|
||||
except Exception as notifError:
|
||||
logger.warning(f"Failed to create notification for user {data.targetUsername}: {notifError}")
|
||||
# Don't fail the invitation if notification fails
|
||||
|
||||
logger.info(
|
||||
f"User {context.user.id} created invitation for user {data.targetUsername} "
|
||||
f"to mandate {context.mandateId}, expires in {data.expiresInHours}h"
|
||||
)
|
||||
|
||||
return InvitationResponse(
|
||||
id=str(createdRecord.get("id")),
|
||||
token=str(createdRecord.get("token")),
|
||||
mandateId=str(createdRecord.get("mandateId")),
|
||||
featureInstanceId=createdRecord.get("featureInstanceId"),
|
||||
roleIds=createdRecord.get("roleIds", []),
|
||||
targetUsername=createdRecord.get("targetUsername"),
|
||||
email=createdRecord.get("email"),
|
||||
createdBy=str(createdRecord.get("createdBy")),
|
||||
createdAt=createdRecord.get("createdAt"),
|
||||
expiresAt=createdRecord.get("expiresAt"),
|
||||
usedBy=createdRecord.get("usedBy"),
|
||||
usedAt=createdRecord.get("usedAt"),
|
||||
revokedAt=createdRecord.get("revokedAt"),
|
||||
maxUses=createdRecord.get("maxUses", 1),
|
||||
currentUses=createdRecord.get("currentUses", 0),
|
||||
inviteUrl=inviteUrl,
|
||||
emailSent=emailSent
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating invitation: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create invitation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def list_invitations(
|
||||
request: Request,
|
||||
includeUsed: bool = Query(False, description="Include already used invitations"),
|
||||
includeExpired: bool = Query(False, description="Include expired invitations"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List invitations for the current mandate.
|
||||
|
||||
Requires Mandate-Admin role. Returns all invitations created for this mandate.
|
||||
|
||||
Args:
|
||||
includeUsed: Include invitations that have reached maxUses
|
||||
includeExpired: Include expired invitations
|
||||
"""
|
||||
if not context.mandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to list invitations"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get all invitations for this mandate
|
||||
allInvitations = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"mandateId": str(context.mandateId)}
|
||||
)
|
||||
|
||||
currentTime = getUtcTimestamp()
|
||||
result = []
|
||||
|
||||
for inv in allInvitations:
|
||||
# Skip revoked invitations
|
||||
if inv.get("revokedAt"):
|
||||
continue
|
||||
|
||||
# Filter by usage
|
||||
if not includeUsed and inv.get("currentUses", 0) >= inv.get("maxUses", 1):
|
||||
continue
|
||||
|
||||
# Filter by expiration
|
||||
if not includeExpired and inv.get("expiresAt", 0) < currentTime:
|
||||
continue
|
||||
|
||||
# Build invite URL
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
|
||||
inviteUrl = f"{frontendUrl}/invite/{inv.get('token')}"
|
||||
|
||||
result.append({
|
||||
**{k: v for k, v in inv.items() if not k.startswith("_")},
|
||||
"inviteUrl": inviteUrl,
|
||||
"isExpired": inv.get("expiresAt", 0) < currentTime,
|
||||
"isUsedUp": inv.get("currentUses", 0) >= inv.get("maxUses", 1)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing invitations: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list invitations: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{invitationId}", response_model=Dict[str, str])
|
||||
@limiter.limit("30/minute")
|
||||
async def revoke_invitation(
|
||||
request: Request,
|
||||
invitationId: str,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Revoke an invitation.
|
||||
|
||||
Requires Mandate-Admin role. Revoked invitations cannot be used.
|
||||
|
||||
Args:
|
||||
invitationId: Invitation ID
|
||||
"""
|
||||
if not context.mandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to revoke invitations"
|
||||
)
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get invitation
|
||||
invitationRecords = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"id": invitationId}
|
||||
)
|
||||
|
||||
if not invitationRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Invitation '{invitationId}' not found"
|
||||
)
|
||||
|
||||
invitation = invitationRecords[0]
|
||||
|
||||
# Verify mandate access
|
||||
if str(invitation.get("mandateId")) != str(context.mandateId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this invitation"
|
||||
)
|
||||
|
||||
# Already revoked?
|
||||
if invitation.get("revokedAt"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation is already revoked"
|
||||
)
|
||||
|
||||
# Revoke invitation
|
||||
rootInterface.db.recordModify(
|
||||
Invitation,
|
||||
invitationId,
|
||||
{"revokedAt": getUtcTimestamp()}
|
||||
)
|
||||
|
||||
logger.info(f"User {context.user.id} revoked invitation {invitationId}")
|
||||
|
||||
return {"message": "Invitation revoked", "invitationId": invitationId}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking invitation {invitationId}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to revoke invitation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public Invitation Endpoints (No auth required for validation)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/validate/{token}", response_model=InvitationValidation)
|
||||
@limiter.limit("30/minute")
|
||||
async def validate_invitation(
|
||||
request: Request,
|
||||
token: str
|
||||
) -> InvitationValidation:
|
||||
"""
|
||||
Validate an invitation token (public endpoint).
|
||||
|
||||
Used by the frontend to check if an invitation is valid before
|
||||
showing the registration/acceptance form.
|
||||
|
||||
Args:
|
||||
token: Invitation token
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Find invitation by token
|
||||
invitationRecords = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"token": token}
|
||||
)
|
||||
|
||||
if not invitationRecords:
|
||||
return InvitationValidation(
|
||||
valid=False,
|
||||
reason="Invitation not found",
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
roleIds=[]
|
||||
)
|
||||
|
||||
invitation = invitationRecords[0]
|
||||
|
||||
# Check if revoked
|
||||
if invitation.get("revokedAt"):
|
||||
return InvitationValidation(
|
||||
valid=False,
|
||||
reason="Invitation has been revoked",
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
roleIds=[]
|
||||
)
|
||||
|
||||
# Check if expired
|
||||
currentTime = getUtcTimestamp()
|
||||
if invitation.get("expiresAt", 0) < currentTime:
|
||||
return InvitationValidation(
|
||||
valid=False,
|
||||
reason="Invitation has expired",
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
roleIds=[]
|
||||
)
|
||||
|
||||
# Check if used up
|
||||
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
|
||||
return InvitationValidation(
|
||||
valid=False,
|
||||
reason="Invitation has reached maximum uses",
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
roleIds=[]
|
||||
)
|
||||
|
||||
# Get additional info for display
|
||||
mandateId = invitation.get("mandateId")
|
||||
mandateName = None
|
||||
roleLabels = []
|
||||
targetUsername = invitation.get("targetUsername")
|
||||
|
||||
# Get mandate name
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
mandateRecords = rootInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": mandateId}
|
||||
)
|
||||
if mandateRecords:
|
||||
mandateName = mandateRecords[0].get("name")
|
||||
|
||||
# Get role names
|
||||
roleIds = invitation.get("roleIds", [])
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
for roleId in roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
roleLabels.append(roleRecords[0].get("roleLabel", roleId))
|
||||
|
||||
return InvitationValidation(
|
||||
valid=True,
|
||||
reason=None,
|
||||
mandateId=mandateId,
|
||||
mandateName=mandateName,
|
||||
featureInstanceId=invitation.get("featureInstanceId"),
|
||||
roleIds=roleIds,
|
||||
roleLabels=roleLabels,
|
||||
targetUsername=targetUsername
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating invitation token: {e}")
|
||||
return InvitationValidation(
|
||||
valid=False,
|
||||
reason="Validation error",
|
||||
mandateId=None,
|
||||
featureInstanceId=None,
|
||||
roleIds=[]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/accept/{token}", response_model=Dict[str, Any])
|
||||
@limiter.limit("10/minute")
|
||||
async def accept_invitation(
|
||||
request: Request,
|
||||
token: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Accept an invitation (requires authentication).
|
||||
|
||||
The authenticated user joins the mandate with the predefined roles.
|
||||
If the user is already a member, their roles are updated.
|
||||
|
||||
Args:
|
||||
token: Invitation token
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Find invitation by token
|
||||
invitationRecords = rootInterface.db.getRecordset(
|
||||
Invitation,
|
||||
recordFilter={"token": token}
|
||||
)
|
||||
|
||||
if not invitationRecords:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invitation not found"
|
||||
)
|
||||
|
||||
invitation = invitationRecords[0]
|
||||
|
||||
# Validate invitation
|
||||
if invitation.get("revokedAt"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has been revoked"
|
||||
)
|
||||
|
||||
currentTime = getUtcTimestamp()
|
||||
if invitation.get("expiresAt", 0) < currentTime:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has expired"
|
||||
)
|
||||
|
||||
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has reached maximum uses"
|
||||
)
|
||||
|
||||
# Validate username matches - the invitation is bound to a specific user
|
||||
targetUsername = invitation.get("targetUsername")
|
||||
if targetUsername and currentUser.username != targetUsername:
|
||||
logger.warning(
|
||||
f"User {currentUser.username} tried to accept invitation meant for {targetUsername}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Diese Einladung ist für Benutzer '{targetUsername}' bestimmt"
|
||||
)
|
||||
|
||||
mandateId = invitation.get("mandateId")
|
||||
roleIds = invitation.get("roleIds", [])
|
||||
featureInstanceId = invitation.get("featureInstanceId")
|
||||
|
||||
# Check if user is already a member
|
||||
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
|
||||
|
||||
if existingMembership:
|
||||
# Update existing membership with additional roles
|
||||
for roleId in roleIds:
|
||||
try:
|
||||
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
|
||||
except Exception:
|
||||
pass # Role might already be assigned
|
||||
|
||||
userMandateId = str(existingMembership.id)
|
||||
message = "Roles updated for existing membership"
|
||||
else:
|
||||
# Create new membership
|
||||
userMandate = rootInterface.createUserMandate(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=mandateId,
|
||||
roleIds=roleIds
|
||||
)
|
||||
userMandateId = str(userMandate.id)
|
||||
message = "Successfully joined mandate"
|
||||
|
||||
# Grant feature access if specified
|
||||
featureAccessId = None
|
||||
if featureInstanceId:
|
||||
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
|
||||
if not existingAccess:
|
||||
# Create feature access with instance-level roles if any
|
||||
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
|
||||
featureAccess = rootInterface.createFeatureAccess(
|
||||
userId=str(currentUser.id),
|
||||
featureInstanceId=featureInstanceId,
|
||||
roleIds=instanceRoleIds
|
||||
)
|
||||
featureAccessId = str(featureAccess.id)
|
||||
|
||||
# Update invitation usage
|
||||
rootInterface.db.recordModify(
|
||||
Invitation,
|
||||
invitation.get("id"),
|
||||
{
|
||||
"currentUses": invitation.get("currentUses", 0) + 1,
|
||||
"usedBy": str(currentUser.id),
|
||||
"usedAt": currentTime
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User {currentUser.id} accepted invitation {invitation.get('id')} "
|
||||
f"for mandate {mandateId}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": message,
|
||||
"mandateId": mandateId,
|
||||
"userMandateId": userMandateId,
|
||||
"featureAccessId": featureAccessId,
|
||||
"roleIds": roleIds
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error accepting invitation: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to accept invitation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||
"""
|
||||
Check if the user has mandate admin role in the current context.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
return False
|
||||
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
|
||||
for roleId in context.roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
roleLabel = role.get("roleLabel", "")
|
||||
# Admin role at mandate level
|
||||
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking mandate admin role: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _isInstanceRole(interface, roleId: str, featureInstanceId: str) -> bool:
|
||||
"""
|
||||
Check if a role belongs to a specific feature instance.
|
||||
"""
|
||||
try:
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
return str(role.get("featureInstanceId", "")) == str(featureInstanceId)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -7,10 +7,11 @@ import logging
|
|||
import json
|
||||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||
from modules.datamodels.datamodelMessaging import (
|
||||
MessagingSubscription,
|
||||
MessagingSubscriptionRegistration,
|
||||
|
|
@ -37,7 +38,7 @@ router = APIRouter(
|
|||
|
||||
@router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription])
|
||||
@limiter.limit("60/minute")
|
||||
async def getSubscriptions(
|
||||
async def get_subscriptions(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
|
|
@ -54,7 +55,7 @@ async def getSubscriptions(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getAllSubscriptions(pagination=paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
|
|
@ -78,13 +79,13 @@ async def getSubscriptions(
|
|||
|
||||
@router.post("/subscriptions", response_model=MessagingSubscription)
|
||||
@limiter.limit("60/minute")
|
||||
async def createSubscription(
|
||||
async def create_subscription(
|
||||
request: Request,
|
||||
subscription: MessagingSubscription,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingSubscription:
|
||||
"""Create a new subscription"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
subscriptionData = subscription.model_dump(exclude={"id"})
|
||||
newSubscription = managementInterface.createSubscription(subscriptionData)
|
||||
|
|
@ -94,13 +95,13 @@ async def createSubscription(
|
|||
|
||||
@router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
|
||||
@limiter.limit("60/minute")
|
||||
async def getSubscription(
|
||||
async def get_subscription(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingSubscription:
|
||||
"""Get a specific subscription"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
subscription = managementInterface.getSubscription(subscriptionId)
|
||||
if not subscription:
|
||||
|
|
@ -114,14 +115,14 @@ async def getSubscription(
|
|||
|
||||
@router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
|
||||
@limiter.limit("60/minute")
|
||||
async def updateSubscription(
|
||||
async def update_subscription(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription to update"),
|
||||
subscriptionData: MessagingSubscription = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingSubscription:
|
||||
"""Update an existing subscription"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
existingSubscription = managementInterface.getSubscription(subscriptionId)
|
||||
if not existingSubscription:
|
||||
|
|
@ -144,13 +145,13 @@ async def updateSubscription(
|
|||
|
||||
@router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def deleteSubscription(
|
||||
async def delete_subscription(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a subscription"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
existingSubscription = managementInterface.getSubscription(subscriptionId)
|
||||
if not existingSubscription:
|
||||
|
|
@ -173,7 +174,7 @@ async def deleteSubscription(
|
|||
|
||||
@router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
|
||||
@limiter.limit("60/minute")
|
||||
async def getSubscriptionRegistrations(
|
||||
async def get_subscription_registrations(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
|
|
@ -191,7 +192,7 @@ async def getSubscriptionRegistrations(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getAllRegistrations(
|
||||
subscriptionId=subscriptionId,
|
||||
pagination=paginationParams
|
||||
|
|
@ -218,7 +219,7 @@ async def getSubscriptionRegistrations(
|
|||
|
||||
@router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration)
|
||||
@limiter.limit("60/minute")
|
||||
async def subscribeUser(
|
||||
async def subscribe_user(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription"),
|
||||
channel: MessagingChannel = Body(..., embed=True),
|
||||
|
|
@ -226,7 +227,7 @@ async def subscribeUser(
|
|||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingSubscriptionRegistration:
|
||||
"""Subscribe user to a subscription with a specific channel"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
registration = managementInterface.subscribeUser(
|
||||
subscriptionId=subscriptionId,
|
||||
|
|
@ -240,14 +241,14 @@ async def subscribeUser(
|
|||
|
||||
@router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def unsubscribeUser(
|
||||
async def unsubscribe_user(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription"),
|
||||
channel: MessagingChannel = Body(..., embed=True),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Unsubscribe user from a subscription for a specific channel"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
success = managementInterface.unsubscribeUser(
|
||||
subscriptionId=subscriptionId,
|
||||
|
|
@ -266,7 +267,7 @@ async def unsubscribeUser(
|
|||
|
||||
@router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
|
||||
@limiter.limit("60/minute")
|
||||
async def getMyRegistrations(
|
||||
async def get_my_registrations(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
|
|
@ -283,7 +284,7 @@ async def getMyRegistrations(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getAllRegistrations(
|
||||
userId=currentUser.id,
|
||||
pagination=paginationParams
|
||||
|
|
@ -310,14 +311,14 @@ async def getMyRegistrations(
|
|||
|
||||
@router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration)
|
||||
@limiter.limit("60/minute")
|
||||
async def updateRegistration(
|
||||
async def update_registration(
|
||||
request: Request,
|
||||
registrationId: str = Path(..., description="ID of the registration to update"),
|
||||
registrationData: MessagingSubscriptionRegistration = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingSubscriptionRegistration:
|
||||
"""Update a registration"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
existingRegistration = managementInterface.getRegistration(registrationId)
|
||||
if not existingRegistration:
|
||||
|
|
@ -340,13 +341,13 @@ async def updateRegistration(
|
|||
|
||||
@router.delete("/registrations/{registrationId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def deleteRegistration(
|
||||
async def delete_registration(
|
||||
request: Request,
|
||||
registrationId: str = Path(..., description="ID of the registration to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a registration"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
existingRegistration = managementInterface.getRegistration(registrationId)
|
||||
if not existingRegistration:
|
||||
|
|
@ -375,20 +376,27 @@ def _getTriggerKey(request: Request) -> str:
|
|||
|
||||
@router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult)
|
||||
@limiter.limit("60/minute", key_func=_getTriggerKey)
|
||||
async def triggerSubscription(
|
||||
async def trigger_subscription(
|
||||
request: Request,
|
||||
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
|
||||
eventParameters: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> MessagingSubscriptionExecutionResult:
|
||||
"""Trigger a subscription with event parameters"""
|
||||
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern
|
||||
# TODO: Add proper RBAC check here
|
||||
"""
|
||||
Trigger a subscription with event parameters.
|
||||
|
||||
Requires Mandate-Admin role or SysAdmin.
|
||||
"""
|
||||
# RBAC-Check: Admin or Mandate-Admin can trigger
|
||||
if not _hasTriggerPermission(context):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin or Mandate-Admin role required to trigger subscriptions"
|
||||
)
|
||||
|
||||
# Get messaging service from request app state
|
||||
# We need to access services through the request
|
||||
from modules.services import getInterface as getServicesInterface
|
||||
services = getServicesInterface(currentUser, None)
|
||||
services = getServicesInterface(context.user, None, mandateId=str(context.mandateId))
|
||||
|
||||
# Konvertiere Dict zu Pydantic Model
|
||||
eventParams = MessagingEventParameters(triggerData=eventParameters)
|
||||
|
|
@ -397,11 +405,42 @@ async def triggerSubscription(
|
|||
return executionResult
|
||||
|
||||
|
||||
def _hasTriggerPermission(context: RequestContext) -> bool:
|
||||
"""
|
||||
Check if user has permission to trigger subscriptions.
|
||||
Requires admin or mandate-admin role.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
return False
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
for roleId in context.roleIds:
|
||||
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roleRecords:
|
||||
role = roleRecords[0]
|
||||
roleLabel = role.get("roleLabel", "")
|
||||
# Admin role at mandate level or system admin
|
||||
if roleLabel in ("admin", "sysadmin"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking trigger permission: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Delivery Endpoints
|
||||
|
||||
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
|
||||
@limiter.limit("60/minute")
|
||||
async def getDeliveries(
|
||||
async def get_deliveries(
|
||||
request: Request,
|
||||
subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
|
|
@ -419,7 +458,7 @@ async def getDeliveries(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
result = managementInterface.getDeliveries(
|
||||
subscriptionId=subscriptionId,
|
||||
userId=currentUser.id, # Users can only see their own deliveries
|
||||
|
|
@ -447,13 +486,13 @@ async def getDeliveries(
|
|||
|
||||
@router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery)
|
||||
@limiter.limit("60/minute")
|
||||
async def getDelivery(
|
||||
async def get_delivery(
|
||||
request: Request,
|
||||
deliveryId: str = Path(..., description="ID of the delivery"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> MessagingDelivery:
|
||||
"""Get a specific delivery"""
|
||||
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
|
||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||
|
||||
delivery = managementInterface.getDelivery(deliveryId)
|
||||
if not delivery:
|
||||
|
|
|
|||
587
modules/routes/routeNotifications.py
Normal file
587
modules/routes/routeNotifications.py
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Notification routes for in-app notifications.
|
||||
Provides user-specific notification inbox with support for actionable notifications.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelNotification import (
|
||||
UserNotification,
|
||||
NotificationType,
|
||||
NotificationStatus,
|
||||
NotificationAction
|
||||
)
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/notifications",
|
||||
tags=["Notifications"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Models
|
||||
# =============================================================================
|
||||
|
||||
class NotificationActionRequest(BaseModel):
|
||||
"""Request model for executing a notification action"""
|
||||
actionId: str = Field(..., description="ID of the action to execute (e.g., 'accept', 'decline')")
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Response model for unread count"""
|
||||
count: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
def _createNotification(
|
||||
userId: str,
|
||||
notificationType: NotificationType,
|
||||
title: str,
|
||||
message: str,
|
||||
referenceType: Optional[str] = None,
|
||||
referenceId: Optional[str] = None,
|
||||
actions: Optional[List[NotificationAction]] = None,
|
||||
icon: Optional[str] = None,
|
||||
expiresAt: Optional[float] = None
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for a user.
|
||||
This is a helper function that can be imported by other modules.
|
||||
"""
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
notification = UserNotification(
|
||||
userId=userId,
|
||||
type=notificationType,
|
||||
title=title,
|
||||
message=message,
|
||||
referenceType=referenceType,
|
||||
referenceId=referenceId,
|
||||
actions=actions,
|
||||
icon=icon,
|
||||
expiresAt=expiresAt
|
||||
)
|
||||
|
||||
# Store in database
|
||||
rootInterface.db.recordCreate(
|
||||
model_class=UserNotification,
|
||||
record=notification.model_dump()
|
||||
)
|
||||
|
||||
logger.info(f"Created notification {notification.id} for user {userId}: {title}")
|
||||
return notification
|
||||
|
||||
|
||||
def createInvitationNotification(
|
||||
userId: str,
|
||||
invitationId: str,
|
||||
mandateName: str,
|
||||
inviterName: str
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a notification for a pending invitation.
|
||||
Called when an invitation is created for an existing user.
|
||||
"""
|
||||
return _createNotification(
|
||||
userId=userId,
|
||||
notificationType=NotificationType.INVITATION,
|
||||
title="Neue Einladung",
|
||||
message=f"{inviterName} hat Sie zu '{mandateName}' eingeladen.",
|
||||
referenceType="Invitation",
|
||||
referenceId=invitationId,
|
||||
icon="mail",
|
||||
actions=[
|
||||
NotificationAction(actionId="accept", label="Annehmen", style="primary"),
|
||||
NotificationAction(actionId="decline", label="Ablehnen", style="danger")
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.get("", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("60/minute")
|
||||
async def getNotifications(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
status: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all notifications for the current user.
|
||||
|
||||
Optionally filter by status (unread, read, actioned, dismissed) or type.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Build filter
|
||||
recordFilter = {"userId": str(currentUser.id)}
|
||||
if status:
|
||||
recordFilter["status"] = status
|
||||
if type:
|
||||
recordFilter["type"] = type
|
||||
|
||||
# Get notifications
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter=recordFilter
|
||||
)
|
||||
|
||||
# Sort by creation date (newest first) and limit
|
||||
notifications = sorted(notifications, key=lambda x: x.get("createdAt", 0), reverse=True)
|
||||
if limit:
|
||||
notifications = notifications[:limit]
|
||||
|
||||
return notifications
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting notifications: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get notifications: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
@limiter.limit("120/minute")
|
||||
async def getUnreadCount(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> UnreadCountResponse:
|
||||
"""
|
||||
Get the count of unread notifications for the current user.
|
||||
Used for the notification badge in the header.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter={
|
||||
"userId": str(currentUser.id),
|
||||
"status": NotificationStatus.UNREAD.value
|
||||
}
|
||||
)
|
||||
|
||||
return UnreadCountResponse(count=len(notifications))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unread count: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get unread count: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{notificationId}/read", response_model=Dict[str, Any])
|
||||
@limiter.limit("60/minute")
|
||||
async def markAsRead(
|
||||
request: Request,
|
||||
notificationId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mark a notification as read.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get the notification
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter={"id": notificationId}
|
||||
)
|
||||
|
||||
if not notifications:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
notification = notifications[0]
|
||||
|
||||
# Verify ownership
|
||||
if notification.get("userId") != currentUser.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this notification"
|
||||
)
|
||||
|
||||
# Update status
|
||||
rootInterface.db.recordModify(
|
||||
model_class=UserNotification,
|
||||
recordId=notificationId,
|
||||
record={
|
||||
"status": NotificationStatus.READ.value,
|
||||
"readAt": getUtcTimestamp()
|
||||
}
|
||||
)
|
||||
|
||||
return {"message": "Notification marked as read", "id": notificationId}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking notification as read: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to mark notification as read: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/mark-all-read", response_model=Dict[str, Any])
|
||||
@limiter.limit("10/minute")
|
||||
async def markAllAsRead(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mark all notifications as read for the current user.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get all unread notifications
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter={
|
||||
"userId": currentUser.id,
|
||||
"status": NotificationStatus.UNREAD.value
|
||||
}
|
||||
)
|
||||
|
||||
currentTime = getUtcTimestamp()
|
||||
updatedCount = 0
|
||||
|
||||
for notification in notifications:
|
||||
rootInterface.db.recordModify(
|
||||
model_class=UserNotification,
|
||||
recordId=notification.get("id"),
|
||||
record={
|
||||
"status": NotificationStatus.READ.value,
|
||||
"readAt": currentTime
|
||||
}
|
||||
)
|
||||
updatedCount += 1
|
||||
|
||||
return {"message": f"Marked {updatedCount} notifications as read", "count": updatedCount}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking all notifications as read: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to mark notifications as read: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{notificationId}/action", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def executeAction(
|
||||
request: Request,
|
||||
notificationId: str,
|
||||
actionRequest: NotificationActionRequest,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute an action on a notification (e.g., accept/decline invitation).
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get the notification
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter={"id": notificationId}
|
||||
)
|
||||
|
||||
if not notifications:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
notification = notifications[0]
|
||||
|
||||
# Verify ownership
|
||||
if notification.get("userId") != currentUser.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this notification"
|
||||
)
|
||||
|
||||
# Check if already actioned
|
||||
if notification.get("status") == NotificationStatus.ACTIONED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Notification has already been actioned"
|
||||
)
|
||||
|
||||
# Validate action exists
|
||||
actions = notification.get("actions", [])
|
||||
validActionIds = [a.get("actionId") if isinstance(a, dict) else a.actionId for a in (actions or [])]
|
||||
|
||||
if actionRequest.actionId not in validActionIds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid action. Valid actions: {validActionIds}"
|
||||
)
|
||||
|
||||
# Execute action based on notification type
|
||||
actionResult = None
|
||||
|
||||
if notification.get("type") == NotificationType.INVITATION.value:
|
||||
actionResult = await _handleInvitationAction(
|
||||
notification=notification,
|
||||
actionId=actionRequest.actionId,
|
||||
currentUser=currentUser,
|
||||
rootInterface=rootInterface
|
||||
)
|
||||
else:
|
||||
# Generic action handling
|
||||
actionResult = f"Action '{actionRequest.actionId}' executed"
|
||||
|
||||
# Update notification status
|
||||
rootInterface.db.recordModify(
|
||||
model_class=UserNotification,
|
||||
recordId=notificationId,
|
||||
record={
|
||||
"status": NotificationStatus.ACTIONED.value,
|
||||
"actionTaken": actionRequest.actionId,
|
||||
"actionResult": actionResult,
|
||||
"actionedAt": getUtcTimestamp()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"message": actionResult,
|
||||
"action": actionRequest.actionId,
|
||||
"notificationId": notificationId
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing notification action: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to execute action: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
async def _handleInvitationAction(
|
||||
notification: Dict[str, Any],
|
||||
actionId: str,
|
||||
currentUser: User,
|
||||
rootInterface
|
||||
) -> str:
|
||||
"""Handle accept/decline actions for invitation notifications."""
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
|
||||
invitationId = notification.get("referenceId")
|
||||
if not invitationId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No invitation reference found"
|
||||
)
|
||||
|
||||
# Get the invitation
|
||||
invitations = rootInterface.db.getRecordset(
|
||||
model_class=Invitation,
|
||||
recordFilter={"id": invitationId}
|
||||
)
|
||||
|
||||
if not invitations:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invitation not found"
|
||||
)
|
||||
|
||||
invitation = invitations[0]
|
||||
|
||||
# Verify username matches
|
||||
if invitation.get("targetUsername") != currentUser.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This invitation is for a different user"
|
||||
)
|
||||
|
||||
# Check if invitation is still valid
|
||||
currentTime = getUtcTimestamp()
|
||||
if invitation.get("expiresAt", 0) < currentTime:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has expired"
|
||||
)
|
||||
|
||||
if invitation.get("revokedAt"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has been revoked"
|
||||
)
|
||||
|
||||
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation has reached maximum uses"
|
||||
)
|
||||
|
||||
if actionId == "accept":
|
||||
# Accept the invitation - assign roles and mandate access
|
||||
mandateId = invitation.get("mandateId")
|
||||
roleIds = invitation.get("roleIds", [])
|
||||
|
||||
# Ensure user gets the system "user" role for access to public UI elements (e.g. playground)
|
||||
userRoles = rootInterface.db.getRecordset(
|
||||
model_class=Role,
|
||||
recordFilter={"roleLabel": "user"}
|
||||
)
|
||||
if userRoles:
|
||||
userRoleId = userRoles[0].get("id")
|
||||
if userRoleId and userRoleId not in roleIds:
|
||||
roleIds = roleIds + [userRoleId]
|
||||
logger.debug(f"Added system 'user' role {userRoleId} to invitation roles")
|
||||
|
||||
# Get mandate name for result message
|
||||
mandates = rootInterface.db.getRecordset(
|
||||
model_class=Mandate,
|
||||
recordFilter={"id": mandateId}
|
||||
)
|
||||
mandateName = mandates[0].get("mandateLabel", mandateId) if mandates else mandateId
|
||||
|
||||
# Check if user already has this mandate
|
||||
existingMemberships = rootInterface.db.getRecordset(
|
||||
model_class=UserMandate,
|
||||
recordFilter={
|
||||
"userId": currentUser.id,
|
||||
"mandateId": mandateId
|
||||
}
|
||||
)
|
||||
|
||||
if existingMemberships:
|
||||
# Update existing membership with new roles
|
||||
existingMembership = existingMemberships[0]
|
||||
existingRoles = existingMembership.get("roleIds", [])
|
||||
mergedRoles = list(set(existingRoles + roleIds))
|
||||
|
||||
rootInterface.db.recordModify(
|
||||
model_class=UserMandate,
|
||||
recordId=existingMembership.get("id"),
|
||||
record={"roleIds": mergedRoles}
|
||||
)
|
||||
logger.info(f"Updated UserMandate for user {currentUser.id} in mandate {mandateId}")
|
||||
else:
|
||||
# Create new user-mandate relationship
|
||||
userMandate = UserMandate(
|
||||
userId=currentUser.id,
|
||||
mandateId=mandateId,
|
||||
roleIds=roleIds
|
||||
)
|
||||
rootInterface.db.recordCreate(
|
||||
model_class=UserMandate,
|
||||
record=userMandate.model_dump()
|
||||
)
|
||||
logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}")
|
||||
|
||||
# Mark invitation as used
|
||||
rootInterface.db.recordModify(
|
||||
model_class=Invitation,
|
||||
recordId=invitationId,
|
||||
record={
|
||||
"usedBy": currentUser.id,
|
||||
"usedAt": currentTime,
|
||||
"currentUses": invitation.get("currentUses", 0) + 1
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"User {currentUser.id} accepted invitation {invitationId} for mandate {mandateId}")
|
||||
return f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'."
|
||||
|
||||
elif actionId == "decline":
|
||||
# Decline the invitation
|
||||
# We don't revoke it, just mark the notification as declined
|
||||
logger.info(f"User {currentUser.id} declined invitation {invitationId}")
|
||||
return "Einladung abgelehnt."
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown action: {actionId}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{notificationId}", response_model=Dict[str, Any])
|
||||
@limiter.limit("30/minute")
|
||||
async def deleteNotification(
|
||||
request: Request,
|
||||
notificationId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete/dismiss a notification.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get the notification
|
||||
notifications = rootInterface.db.getRecordset(
|
||||
model_class=UserNotification,
|
||||
recordFilter={"id": notificationId}
|
||||
)
|
||||
|
||||
if not notifications:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
notification = notifications[0]
|
||||
|
||||
# Verify ownership
|
||||
if notification.get("userId") != currentUser.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to delete this notification"
|
||||
)
|
||||
|
||||
# Mark as dismissed (soft delete)
|
||||
rootInterface.db.recordModify(
|
||||
model_class=UserNotification,
|
||||
recordId=notificationId,
|
||||
record={
|
||||
"status": NotificationStatus.DISMISSED.value
|
||||
}
|
||||
)
|
||||
|
||||
return {"message": "Notification dismissed", "id": notificationId}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting notification: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete notification: {str(e)}"
|
||||
)
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Options API routes for dynamic frontend options.
|
||||
Provides endpoints for fetching options for select/multiselect fields.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Request
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.features.dynamicOptions.mainDynamicOptions import getOptions, getAvailableOptionsNames
|
||||
from modules.services import getInterface as getServices
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/options",
|
||||
tags=["Options"],
|
||||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{optionsName}", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("120/minute")
|
||||
async def getOptionsEndpoint(
|
||||
request: Request,
|
||||
optionsName: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get options for a given options name.
|
||||
|
||||
Path Parameters:
|
||||
- optionsName: Name of the options set (e.g., "user.role", "user.connection")
|
||||
|
||||
Returns:
|
||||
- List of option dictionaries with "value" and "label" keys
|
||||
|
||||
Examples:
|
||||
- GET /api/options/user.role
|
||||
- GET /api/options/user.connection
|
||||
- GET /api/options/auth.authority
|
||||
- GET /api/options/connection.status
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Options request: {optionsName} for user {currentUser.id}")
|
||||
services = getServices(currentUser, None)
|
||||
options = getOptions(optionsName, services, currentUser)
|
||||
logger.debug(f"Options response: {optionsName} returned {len(options)} items")
|
||||
return options
|
||||
except ValueError as e:
|
||||
logger.error(f"ValueError for options {optionsName}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting options for {optionsName}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get options: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[str])
|
||||
@limiter.limit("30/minute")
|
||||
async def listAvailableOptions(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get list of all available options names.
|
||||
|
||||
Returns:
|
||||
- List of available options names
|
||||
"""
|
||||
try:
|
||||
return getAvailableOptionsNames()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing available options: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list options: {str(e)}"
|
||||
)
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Security Administration routes.
|
||||
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||
No mandate context - SysAdmin manages infrastructure, not data.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from typing import Optional, Dict, Any, List
|
||||
import os
|
||||
import logging
|
||||
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||
from modules.auth import getCurrentUser, limiter, requireSysAdmin
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
|
|
@ -26,13 +32,63 @@ router = APIRouter(
|
|||
}
|
||||
)
|
||||
|
||||
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
|
||||
roleLabels = current_user.roleLabels or []
|
||||
if "admin" not in roleLabels and "sysadmin" not in roleLabels:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
||||
if "admin" in roleLabels and "sysadmin" not in roleLabels:
|
||||
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
|
||||
|
||||
def _getPoweronDatabases() -> List[str]:
|
||||
"""Load databases from PostgreSQL host matching poweron_%."""
|
||||
dbHost = APP_CONFIG.get("DB_HOST")
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
# Connect to 'postgres' system database to query all databases
|
||||
connector = DatabaseConnector(
|
||||
dbHost=dbHost,
|
||||
dbDatabase="postgres",
|
||||
dbUser=dbUser,
|
||||
dbPassword=dbPassword,
|
||||
dbPort=dbPort,
|
||||
userId=None
|
||||
)
|
||||
|
||||
try:
|
||||
with connector.connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT datname
|
||||
FROM pg_database
|
||||
WHERE datname LIKE 'poweron_%'
|
||||
AND datistemplate = false
|
||||
ORDER BY datname
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return [row["datname"] for row in rows if row.get("datname")]
|
||||
finally:
|
||||
connector.close()
|
||||
|
||||
|
||||
def _getDatabaseConnector(databaseName: str, userId: str = None) -> DatabaseConnector:
|
||||
"""
|
||||
Create a generic DatabaseConnector for any poweron_* database.
|
||||
Fully dynamic - no interface mapping needed.
|
||||
"""
|
||||
if not databaseName.startswith("poweron_"):
|
||||
raise ValueError(f"Invalid database name: {databaseName}")
|
||||
|
||||
dbHost = APP_CONFIG.get("DB_HOST")
|
||||
dbUser = APP_CONFIG.get("DB_USER")
|
||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||
|
||||
connector = DatabaseConnector(
|
||||
dbHost=dbHost,
|
||||
dbDatabase=databaseName,
|
||||
dbUser=dbUser,
|
||||
dbPassword=dbPassword,
|
||||
dbPort=dbPort,
|
||||
userId=userId
|
||||
)
|
||||
return connector
|
||||
|
||||
|
||||
# ----------------------
|
||||
|
|
@ -43,17 +99,19 @@ def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = N
|
|||
@limiter.limit("30/minute")
|
||||
async def list_tokens(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
userId: Optional[str] = None,
|
||||
authority: Optional[str] = None,
|
||||
sessionId: Optional[str] = None,
|
||||
statusFilter: Optional[str] = None,
|
||||
connectionId: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all tokens in the system.
|
||||
MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view).
|
||||
"""
|
||||
try:
|
||||
appInterface = getRootInterface()
|
||||
target_mandate = currentUser.mandateId
|
||||
_ensure_admin_scope(currentUser, target_mandate)
|
||||
|
||||
recordFilter: Dict[str, Any] = {}
|
||||
if userId:
|
||||
|
|
@ -66,9 +124,7 @@ async def list_tokens(
|
|||
recordFilter["connectionId"] = connectionId
|
||||
if statusFilter:
|
||||
recordFilter["status"] = statusFilter
|
||||
roleLabels = currentUser.roleLabels or []
|
||||
if "admin" in roleLabels and "sysadmin" not in roleLabels:
|
||||
recordFilter["mandateId"] = str(currentUser.mandateId)
|
||||
# MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter)
|
||||
|
||||
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
|
||||
return tokens
|
||||
|
|
@ -83,27 +139,26 @@ async def list_tokens(
|
|||
@limiter.limit("30/minute")
|
||||
async def revoke_tokens_by_user(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Revoke all tokens for a user.
|
||||
MULTI-TENANT: SysAdmin-only, can revoke across all mandates.
|
||||
"""
|
||||
try:
|
||||
userId = payload.get("userId")
|
||||
authority = payload.get("authority")
|
||||
reason = payload.get("reason", "admin revoke")
|
||||
reason = payload.get("reason", "sysadmin revoke")
|
||||
if not userId:
|
||||
raise HTTPException(status_code=400, detail="userId is required")
|
||||
|
||||
appInterface = getRootInterface()
|
||||
# Tenant scope check
|
||||
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
||||
target_mandate = target_user[0].get("mandateId") if target_user else None
|
||||
_ensure_admin_scope(currentUser, target_mandate)
|
||||
|
||||
roleLabels = currentUser.roleLabels or []
|
||||
# MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
|
||||
count = appInterface.revokeTokensByUser(
|
||||
userId=userId,
|
||||
authority=AuthAuthority(authority) if authority else None,
|
||||
mandateId=None if "sysadmin" in roleLabels else str(currentUser.mandateId),
|
||||
mandateId=None, # SysAdmin: no mandate filter
|
||||
revokedBy=currentUser.id,
|
||||
reason=reason
|
||||
)
|
||||
|
|
@ -119,22 +174,23 @@ async def revoke_tokens_by_user(
|
|||
@limiter.limit("30/minute")
|
||||
async def revoke_tokens_by_session(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Revoke all tokens for a specific session.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
userId = payload.get("userId")
|
||||
sessionId = payload.get("sessionId")
|
||||
authority = payload.get("authority", "local")
|
||||
reason = payload.get("reason", "admin session revoke")
|
||||
reason = payload.get("reason", "sysadmin session revoke")
|
||||
if not userId or not sessionId:
|
||||
raise HTTPException(status_code=400, detail="userId and sessionId are required")
|
||||
|
||||
appInterface = getRootInterface()
|
||||
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
||||
target_mandate = target_user[0].get("mandateId") if target_user else None
|
||||
_ensure_admin_scope(currentUser, target_mandate)
|
||||
|
||||
# MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
|
||||
count = appInterface.revokeTokensBySessionId(
|
||||
sessionId=sessionId,
|
||||
userId=userId,
|
||||
|
|
@ -154,22 +210,20 @@ async def revoke_tokens_by_session(
|
|||
@limiter.limit("30/minute")
|
||||
async def revoke_token_by_id(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Revoke a specific token by ID.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
"""
|
||||
try:
|
||||
tokenId = payload.get("tokenId")
|
||||
reason = payload.get("reason", "admin revoke")
|
||||
reason = payload.get("reason", "sysadmin revoke")
|
||||
if not tokenId:
|
||||
raise HTTPException(status_code=400, detail="tokenId is required")
|
||||
appInterface = getRootInterface()
|
||||
# Load token to check tenant scope for admins
|
||||
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
|
||||
if not tokens:
|
||||
return {"revoked": 0}
|
||||
target_mandate = tokens[0].get("mandateId")
|
||||
_ensure_admin_scope(currentUser, target_mandate)
|
||||
|
||||
# MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
|
||||
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
|
||||
return {"revoked": 1 if ok else 0}
|
||||
except HTTPException:
|
||||
|
|
@ -183,29 +237,34 @@ async def revoke_token_by_id(
|
|||
@limiter.limit("10/minute")
|
||||
async def revoke_tokens_by_mandate(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Revoke all tokens for users in a mandate.
|
||||
MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate.
|
||||
"""
|
||||
try:
|
||||
mandateId = payload.get("mandateId")
|
||||
authority = payload.get("authority", "local")
|
||||
reason = payload.get("reason", "admin mandate revoke")
|
||||
reason = payload.get("reason", "sysadmin mandate revoke")
|
||||
if not mandateId:
|
||||
raise HTTPException(status_code=400, detail="mandateId is required")
|
||||
|
||||
_ensure_admin_scope(currentUser, mandateId)
|
||||
|
||||
# Revoke for all users in mandate
|
||||
# MULTI-TENANT: SysAdmin can revoke tokens for any mandate
|
||||
appInterface = getRootInterface()
|
||||
# IMPORTANT: user rows are stored as UserInDB in the database
|
||||
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
|
||||
|
||||
# Get all UserMandate entries for this mandate to find users
|
||||
# Note: In new model, users are linked via UserMandate, not User.mandateId
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
|
||||
total = 0
|
||||
for u in users:
|
||||
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
|
||||
for um in userMandates:
|
||||
total += appInterface.revokeTokensByUser(
|
||||
userId=u["id"],
|
||||
authority=AuthAuthority(authority),
|
||||
mandateId=None,
|
||||
userId=um["userId"],
|
||||
authority=AuthAuthority(authority) if authority else None,
|
||||
mandateId=None, # Revoke all tokens for user
|
||||
revokedBy=currentUser.id,
|
||||
reason=reason
|
||||
)
|
||||
|
|
@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate(
|
|||
@limiter.limit("60/minute")
|
||||
async def download_log(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
log_name: str = "poweron"
|
||||
):
|
||||
_ensure_admin_scope(currentUser)
|
||||
"""
|
||||
Download server logs.
|
||||
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||
"""
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# base_dir -> gateway
|
||||
if log_name == "poweron":
|
||||
|
|
@ -251,33 +313,18 @@ async def download_log(
|
|||
@limiter.limit("10/minute")
|
||||
async def list_databases(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
_ensure_admin_scope(currentUser)
|
||||
|
||||
# Get database names from configuration for each interface
|
||||
databases = []
|
||||
|
||||
# App database (interfaceDbAppObjects.py)
|
||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
||||
if app_db:
|
||||
databases.append(app_db)
|
||||
|
||||
# Chat database (interfaceDbChatObjects.py)
|
||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
||||
if chat_db:
|
||||
databases.append(chat_db)
|
||||
|
||||
# Management database (interfaceDbComponentObjects.py)
|
||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
||||
if management_db:
|
||||
databases.append(management_db)
|
||||
|
||||
# Fallback to default if no databases configured
|
||||
if not databases:
|
||||
databases = ["poweron"]
|
||||
|
||||
return {"databases": databases}
|
||||
"""
|
||||
List all poweron_* databases.
|
||||
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||
"""
|
||||
try:
|
||||
databases = _getPoweronDatabases()
|
||||
return {"databases": databases}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load databases from host: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to load databases from host")
|
||||
|
||||
|
||||
@router.get("/databases/{database_name}/tables")
|
||||
|
|
@ -285,48 +332,28 @@ async def list_databases(
|
|||
async def get_database_tables(
|
||||
request: Request,
|
||||
database_name: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
) -> Dict[str, Any]:
|
||||
_ensure_admin_scope(currentUser)
|
||||
|
||||
# Get all configured database names
|
||||
configured_dbs = []
|
||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
||||
if app_db:
|
||||
configured_dbs.append(app_db)
|
||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
||||
if chat_db:
|
||||
configured_dbs.append(chat_db)
|
||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
||||
if management_db:
|
||||
configured_dbs.append(management_db)
|
||||
|
||||
if not configured_dbs:
|
||||
configured_dbs = ["poweron"]
|
||||
|
||||
if database_name not in configured_dbs:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
||||
"""
|
||||
List tables in a database.
|
||||
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||
"""
|
||||
if not database_name.startswith("poweron_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid database name format")
|
||||
|
||||
connector = None
|
||||
try:
|
||||
# Use the appropriate interface based on database name
|
||||
if database_name == app_db:
|
||||
appInterface = getRootInterface()
|
||||
tables = appInterface.db.getTables()
|
||||
elif database_name == chat_db:
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||
chatInterface = getChatInterface(currentUser)
|
||||
tables = chatInterface.db.getTables()
|
||||
elif database_name == management_db:
|
||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
||||
componentInterface = getComponentInterface(currentUser)
|
||||
tables = componentInterface.db.getTables()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Database not found")
|
||||
|
||||
connector = _getDatabaseConnector(database_name, currentUser.id)
|
||||
tables = connector.getTables()
|
||||
return {"tables": tables}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database tables: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get database tables")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get database tables: {str(e)}")
|
||||
finally:
|
||||
if connector:
|
||||
connector.close()
|
||||
|
||||
|
||||
@router.post("/databases/{database_name}/tables/{table_name}/drop")
|
||||
|
|
@ -335,43 +362,20 @@ async def drop_table(
|
|||
request: Request,
|
||||
database_name: str,
|
||||
table_name: str,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
_ensure_admin_scope(currentUser)
|
||||
|
||||
# Get all configured database names
|
||||
configured_dbs = []
|
||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
||||
if app_db:
|
||||
configured_dbs.append(app_db)
|
||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
||||
if chat_db:
|
||||
configured_dbs.append(chat_db)
|
||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
||||
if management_db:
|
||||
configured_dbs.append(management_db)
|
||||
|
||||
if not configured_dbs:
|
||||
configured_dbs = ["poweron"]
|
||||
|
||||
if database_name not in configured_dbs:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
||||
"""
|
||||
Drop a table from a database.
|
||||
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||
"""
|
||||
if not database_name.startswith("poweron_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid database name format")
|
||||
|
||||
connector = None
|
||||
try:
|
||||
# Use the appropriate interface based on database name
|
||||
if database_name == app_db:
|
||||
interface = getRootInterface()
|
||||
elif database_name == chat_db:
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||
interface = getChatInterface(currentUser)
|
||||
elif database_name == management_db:
|
||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
||||
interface = getComponentInterface(currentUser)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Database not found")
|
||||
|
||||
conn = interface.db.connection
|
||||
connector = _getDatabaseConnector(database_name, currentUser.id)
|
||||
conn = connector.connection
|
||||
with conn.cursor() as cursor:
|
||||
# Check if table exists
|
||||
cursor.execute("""
|
||||
|
|
@ -388,57 +392,50 @@ async def drop_table(
|
|||
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error dropping table: {str(e)}")
|
||||
if 'interface' in locals() and interface and interface.db and interface.db.connection:
|
||||
interface.db.connection.rollback()
|
||||
if connector and connector.connection:
|
||||
connector.connection.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to drop table")
|
||||
finally:
|
||||
if connector:
|
||||
connector.close()
|
||||
|
||||
|
||||
@router.post("/databases/drop")
|
||||
@limiter.limit("5/minute")
|
||||
async def drop_database(
|
||||
request: Request,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
payload: Dict[str, Any] = Body(...)
|
||||
) -> Dict[str, Any]:
|
||||
_ensure_admin_scope(currentUser)
|
||||
db_name = payload.get("database")
|
||||
"""
|
||||
Drop all tables in a database.
|
||||
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||
"""
|
||||
dbName = payload.get("database")
|
||||
|
||||
# Get all configured database names
|
||||
configured_dbs = []
|
||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
||||
if app_db:
|
||||
configured_dbs.append(app_db)
|
||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
||||
if chat_db:
|
||||
configured_dbs.append(chat_db)
|
||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
||||
if management_db:
|
||||
configured_dbs.append(management_db)
|
||||
if not dbName or not dbName.startswith("poweron_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid database name")
|
||||
|
||||
if not configured_dbs:
|
||||
configured_dbs = ["poweron"]
|
||||
|
||||
if not db_name or db_name not in configured_dbs:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
||||
|
||||
# Validate database exists
|
||||
try:
|
||||
# Use the appropriate interface based on database name
|
||||
if db_name == app_db:
|
||||
interface = getRootInterface()
|
||||
elif db_name == chat_db:
|
||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||
interface = getChatInterface(currentUser)
|
||||
elif db_name == management_db:
|
||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
||||
interface = getComponentInterface(currentUser)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Database not found")
|
||||
|
||||
conn = interface.db.connection
|
||||
configuredDbs = _getPoweronDatabases()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load databases from host: {e}")
|
||||
configuredDbs = []
|
||||
|
||||
if configuredDbs and dbName not in configuredDbs:
|
||||
raise HTTPException(status_code=400, detail=f"Database not found. Available: {configuredDbs}")
|
||||
|
||||
connector = None
|
||||
try:
|
||||
connector = _getDatabaseConnector(dbName, currentUser.id)
|
||||
conn = connector.connection
|
||||
with conn.cursor() as cursor:
|
||||
# Drop all user tables (public schema) except system table
|
||||
# Drop all user tables (public schema)
|
||||
cursor.execute("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
|
|
@ -449,12 +446,17 @@ async def drop_database(
|
|||
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
||||
dropped.append(tbl)
|
||||
conn.commit()
|
||||
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{db_name}': {dropped}")
|
||||
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{dbName}': {dropped}")
|
||||
return {"droppedTables": dropped}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error dropping database tables: {str(e)}")
|
||||
if 'interface' in locals() and interface and interface.db and interface.db.connection:
|
||||
interface.db.connection.rollback()
|
||||
if connector and connector.connection:
|
||||
connector.connection.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to drop database tables")
|
||||
finally:
|
||||
if connector:
|
||||
connector.close()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ from requests_oauthlib import OAuth2Session
|
|||
import httpx
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie
|
||||
from modules.auth.tokenManager import TokenManager
|
||||
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -170,7 +171,6 @@ async def login(
|
|||
try:
|
||||
if connectionId:
|
||||
rootInterface = getRootInterface()
|
||||
from modules.datamodels.datamodelUam import UserConnection
|
||||
records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId})
|
||||
if records:
|
||||
record = records[0]
|
||||
|
|
@ -340,11 +340,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
)
|
||||
|
||||
# Create JWT token data (like Microsoft does)
|
||||
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||
jwt_token_data = {
|
||||
"sub": user.username,
|
||||
"mandateId": str(user.mandateId),
|
||||
"userId": str(user.id),
|
||||
"authenticationAuthority": AuthAuthority.GOOGLE.value
|
||||
# NO mandateId in token - stateless multi-tenant design
|
||||
}
|
||||
|
||||
# Create JWT access token
|
||||
|
|
@ -355,11 +356,11 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
|
||||
# Decode token to get jti for database record
|
||||
from jose import jwt
|
||||
from modules.auth import SECRET_KEY, ALGORITHM
|
||||
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
jti = payload.get("jti")
|
||||
|
||||
# Create JWT token with matching id
|
||||
# MULTI-TENANT: Token model no longer has mandateId field
|
||||
token = Token(
|
||||
id=jti,
|
||||
userId=user.id, # Use local user's ID
|
||||
|
|
@ -368,8 +369,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
tokenRefresh=token_response.get("refresh_token", ""),
|
||||
tokenType="bearer",
|
||||
expiresAt=jwt_expires_at.timestamp(),
|
||||
createdAt=getUtcTimestamp(),
|
||||
mandateId=str(user.mandateId)
|
||||
createdAt=getUtcTimestamp()
|
||||
# NO mandateId - Token is not mandate-bound
|
||||
)
|
||||
|
||||
# Save access token (no connectionId)
|
||||
|
|
@ -492,7 +493,6 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
connection.externalEmail = user_info.get("email")
|
||||
|
||||
# Update connection record directly
|
||||
from modules.datamodels.datamodelUam import UserConnection
|
||||
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
||||
|
||||
|
||||
|
|
@ -615,13 +615,17 @@ async def logout(
|
|||
appInterface.logout()
|
||||
|
||||
# Log successful logout
|
||||
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
mandateId="system",
|
||||
action="logout",
|
||||
successInfo="google_auth_logout"
|
||||
successInfo="google_auth_logout",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
userAgent=request.headers.get("user-agent"),
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
|
|
@ -661,7 +665,6 @@ async def verify_token(
|
|||
)
|
||||
|
||||
# Get a fresh token via TokenManager convenience method
|
||||
from modules.auth import TokenManager
|
||||
current_token = TokenManager().getFreshToken(google_connection.id)
|
||||
|
||||
if not current_token:
|
||||
|
|
@ -735,7 +738,6 @@ async def refresh_token(
|
|||
logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}")
|
||||
|
||||
# Get the token for this specific connection (fresh if expiring soon)
|
||||
from modules.auth import TokenManager
|
||||
current_token = TokenManager().getFreshToken(google_connection.id)
|
||||
|
||||
if not current_token:
|
||||
|
|
|
|||
|
|
@ -16,14 +16,64 @@ from jose import jwt
|
|||
# Import auth modules
|
||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
|
||||
"""
|
||||
Send authentication-related email directly without requiring full Services initialization.
|
||||
Used for registration, password reset, and other auth flows.
|
||||
|
||||
Args:
|
||||
recipient: Email address
|
||||
subject: Email subject
|
||||
message: Plain text message (will be converted to HTML)
|
||||
userId: Optional user ID for logging
|
||||
|
||||
Returns:
|
||||
bool: True if email was sent successfully
|
||||
"""
|
||||
try:
|
||||
import html
|
||||
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||
from modules.datamodels.datamodelMessaging import MessagingChannel
|
||||
|
||||
# Convert plain text to simple HTML
|
||||
escaped = html.escape(message)
|
||||
escaped = escaped.replace('\n', '<br>\n')
|
||||
htmlMessage = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
|
||||
{escaped}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
messagingInterface = getMessagingInterface()
|
||||
success = messagingInterface.send(
|
||||
channel=MessagingChannel.EMAIL,
|
||||
recipient=recipient,
|
||||
subject=subject,
|
||||
message=htmlMessage
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
|
||||
else:
|
||||
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
|
||||
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
# Create router for Local Security endpoints
|
||||
router = APIRouter(
|
||||
prefix="/api/local",
|
||||
|
|
@ -57,19 +107,9 @@ async def login(
|
|||
# Get gateway interface with root privileges for authentication
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
# Get default mandate ID
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
defaultMandateId = rootInterface.getInitialId(Mandate)
|
||||
if not defaultMandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No default mandate found"
|
||||
)
|
||||
|
||||
# Set the mandate ID on the interface
|
||||
rootInterface.mandateId = defaultMandateId
|
||||
|
||||
# Authenticate user
|
||||
# Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC
|
||||
# This is correct because users are mandate-independent (Multi-Tenant Design)
|
||||
user = rootInterface.authenticateLocalUser(
|
||||
username=formData.username,
|
||||
password=formData.password
|
||||
|
|
@ -83,11 +123,13 @@ async def login(
|
|||
)
|
||||
|
||||
# Create token data
|
||||
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||
# Mandate context is determined per request via X-Mandate-Id header
|
||||
token_data = {
|
||||
"sub": user.username,
|
||||
"mandateId": str(user.mandateId),
|
||||
"userId": str(user.id),
|
||||
"authenticationAuthority": AuthAuthority.LOCAL
|
||||
# NO mandateId in token - stateless multi-tenant design
|
||||
}
|
||||
|
||||
# Create session id and include in token claims for session-scoped logout
|
||||
|
|
@ -116,7 +158,8 @@ async def login(
|
|||
# Get jti from already decoded payload
|
||||
jti = payload.get("jti")
|
||||
|
||||
# Create token
|
||||
# Create token record in database
|
||||
# MULTI-TENANT: Token model no longer has mandateId field
|
||||
token = Token(
|
||||
id=jti,
|
||||
userId=user.id,
|
||||
|
|
@ -124,21 +167,25 @@ async def login(
|
|||
tokenAccess=access_token,
|
||||
tokenType="bearer",
|
||||
expiresAt=expires_at.timestamp(),
|
||||
sessionId=session_id,
|
||||
mandateId=str(user.mandateId)
|
||||
sessionId=session_id
|
||||
# NO mandateId - Token is not mandate-bound
|
||||
)
|
||||
|
||||
# Save access token
|
||||
userInterface.saveAccessToken(token)
|
||||
|
||||
# Log successful login
|
||||
# MULTI-TENANT: Login is a system-level function, no mandate context
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
userId=str(user.id),
|
||||
mandateId=str(user.mandateId),
|
||||
mandateId="system",
|
||||
action="login",
|
||||
successInfo="local_auth_success"
|
||||
successInfo="local_auth_success",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
userAgent=request.headers.get("user-agent"),
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
|
|
@ -167,10 +214,13 @@ async def login(
|
|||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
userId="unknown",
|
||||
mandateId="unknown",
|
||||
action="login",
|
||||
successInfo=f"failed: {error_msg}"
|
||||
userId=formData.username or "unknown",
|
||||
mandateId="system",
|
||||
action="login_failed",
|
||||
successInfo=f"failed: {error_msg}",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
userAgent=request.headers.get("user-agent"),
|
||||
success=False
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
|
|
@ -207,17 +257,9 @@ async def register_user(
|
|||
# Get gateway interface with root privileges since this is a public endpoint
|
||||
appInterface = getRootInterface()
|
||||
|
||||
# Get default mandate ID
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
defaultMandateId = appInterface.getInitialId(Mandate)
|
||||
if not defaultMandateId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No default mandate found"
|
||||
)
|
||||
|
||||
# Set the mandate ID on the interface
|
||||
appInterface.mandateId = defaultMandateId
|
||||
# Note: User registration does NOT require mandateId context
|
||||
# Users are mandate-independent (Multi-Tenant Design)
|
||||
# Mandate assignment happens via createUserMandate() after registration
|
||||
|
||||
# Frontend URL is required - no fallback
|
||||
baseUrl = frontendUrl.rstrip("/")
|
||||
|
|
@ -236,7 +278,6 @@ async def register_user(
|
|||
fullName=userData.fullName,
|
||||
language=userData.language,
|
||||
enabled=True, # Users are enabled by default (can login after setting password)
|
||||
roleLabels=["user"], # Default role for new registrations
|
||||
authenticationAuthority=AuthAuthority.LOCAL
|
||||
)
|
||||
|
||||
|
|
@ -252,15 +293,11 @@ async def register_user(
|
|||
|
||||
# Send registration email with magic link
|
||||
try:
|
||||
from modules.services import Services
|
||||
services = Services(user)
|
||||
|
||||
magicLink = f"{baseUrl}/reset?token={token}"
|
||||
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
||||
|
||||
emailSubject = "PowerOn Registrierung - Passwort setzen"
|
||||
emailBody = f"""
|
||||
Hallo {user.fullName or user.username},
|
||||
emailBody = f"""Hallo {user.fullName or user.username},
|
||||
|
||||
Vielen Dank für Ihre Registrierung bei PowerOn.
|
||||
|
||||
|
|
@ -271,10 +308,9 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
|
|||
|
||||
Dieser Link ist {expiryHours} Stunden gültig.
|
||||
|
||||
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.
|
||||
"""
|
||||
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
|
||||
|
||||
emailSent = services.messaging.sendEmailDirect(
|
||||
emailSent = _sendAuthEmail(
|
||||
recipient=user.email,
|
||||
subject=emailSubject,
|
||||
message=emailBody,
|
||||
|
|
@ -287,6 +323,52 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.
|
|||
logger.error(f"Error sending registration email: {str(emailErr)}")
|
||||
# Don't fail registration if email fails - user can request reset later
|
||||
|
||||
# Check for pending invitations and create notifications
|
||||
try:
|
||||
from modules.datamodels.datamodelInvitation import Invitation
|
||||
from modules.routes.routeNotifications import createInvitationNotification
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
|
||||
currentTime = getUtcTimestamp()
|
||||
pendingInvitations = appInterface.db.getRecordset(
|
||||
model_class=Invitation,
|
||||
recordFilter={"targetUsername": userData.username}
|
||||
)
|
||||
|
||||
for invitation in pendingInvitations:
|
||||
# Skip expired, revoked, or fully used invitations
|
||||
if invitation.get("expiresAt", 0) < currentTime:
|
||||
continue
|
||||
if invitation.get("revokedAt"):
|
||||
continue
|
||||
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
|
||||
continue
|
||||
|
||||
# Get mandate name for notification
|
||||
mandateId = invitation.get("mandateId")
|
||||
mandateRecords = appInterface.db.getRecordset(
|
||||
Mandate,
|
||||
recordFilter={"id": mandateId}
|
||||
)
|
||||
mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn"
|
||||
|
||||
# Get inviter name
|
||||
inviterId = invitation.get("createdBy")
|
||||
inviter = appInterface.getUserById(inviterId) if inviterId else None
|
||||
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
|
||||
|
||||
createInvitationNotification(
|
||||
userId=str(user.id),
|
||||
invitationId=str(invitation.get("id")),
|
||||
mandateName=mandateName,
|
||||
inviterName=inviterName
|
||||
)
|
||||
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.get('id')}")
|
||||
|
||||
except Exception as notifErr:
|
||||
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
|
||||
# Don't fail registration if notification creation fails
|
||||
|
||||
return {
|
||||
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
||||
}
|
||||
|
|
@ -358,11 +440,12 @@ async def refresh_token(
|
|||
raise HTTPException(status_code=500, detail="Failed to validate user")
|
||||
|
||||
# Create new token data
|
||||
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||
token_data = {
|
||||
"sub": current_user.username,
|
||||
"mandateId": str(current_user.mandateId),
|
||||
"userId": str(current_user.id),
|
||||
"authenticationAuthority": current_user.authenticationAuthority
|
||||
# NO mandateId in token
|
||||
}
|
||||
|
||||
# Create new access token + set cookie
|
||||
|
|
@ -427,13 +510,17 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
|
|||
revoked = 1
|
||||
|
||||
# Log successful logout
|
||||
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
mandateId="system",
|
||||
action="logout",
|
||||
successInfo=f"revoked_tokens: {revoked}"
|
||||
successInfo=f"revoked_tokens: {revoked}",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
userAgent=request.headers.get("user-agent"),
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
|
|
@ -492,7 +579,7 @@ async def check_username_availability(
|
|||
|
||||
@router.post("/password-reset-request")
|
||||
@limiter.limit("5/minute")
|
||||
async def passwordResetRequest(
|
||||
async def password_reset_request(
|
||||
request: Request,
|
||||
username: str = Body(..., embed=True),
|
||||
frontendUrl: str = Body(..., embed=True)
|
||||
|
|
@ -515,7 +602,6 @@ async def passwordResetRequest(
|
|||
user = rootInterface.findUserByUsernameLocalAuth(username)
|
||||
|
||||
if user and user.email:
|
||||
from modules.services import Services
|
||||
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
||||
|
||||
try:
|
||||
|
|
@ -525,16 +611,12 @@ async def passwordResetRequest(
|
|||
# Set reset token (clears password)
|
||||
rootInterface.setResetToken(user.id, token, expires)
|
||||
|
||||
# Get services for email sending
|
||||
services = Services(user)
|
||||
|
||||
# Generate magic link using provided frontend URL
|
||||
magicLink = f"{baseUrl}/reset?token={token}"
|
||||
|
||||
# Send email
|
||||
# Send email using dedicated auth email function
|
||||
emailSubject = "PowerOn - Passwort zurücksetzen"
|
||||
emailBody = f"""
|
||||
Hallo {user.fullName or user.username},
|
||||
emailBody = f"""Hallo {user.fullName or user.username},
|
||||
|
||||
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
|
||||
|
||||
|
|
@ -545,17 +627,19 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
|
|||
|
||||
Dieser Link ist {expiryHours} Stunden gültig.
|
||||
|
||||
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.
|
||||
"""
|
||||
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
|
||||
|
||||
services.messaging.sendEmailDirect(
|
||||
emailSent = _sendAuthEmail(
|
||||
recipient=user.email,
|
||||
subject=emailSubject,
|
||||
message=emailBody,
|
||||
userId=str(user.id)
|
||||
)
|
||||
|
||||
logger.info(f"Password reset email sent to {user.email} for user {user.username}")
|
||||
if emailSent:
|
||||
logger.info(f"Password reset email sent to {user.email} for user {user.username}")
|
||||
else:
|
||||
logger.warning(f"Failed to send password reset email to {user.email}")
|
||||
except Exception as userErr:
|
||||
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
|
||||
else:
|
||||
|
|
@ -575,7 +659,7 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
|
|||
|
||||
@router.post("/password-reset")
|
||||
@limiter.limit("10/minute")
|
||||
async def passwordReset(
|
||||
async def password_reset(
|
||||
request: Request,
|
||||
token: str = Body(..., embed=True),
|
||||
password: str = Body(..., embed=True)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ import msal
|
|||
import httpx
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie
|
||||
from modules.auth.tokenManager import TokenManager
|
||||
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -348,11 +349,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
appInterface.saveAccessToken(token)
|
||||
|
||||
# Create JWT token data
|
||||
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||
jwt_token_data = {
|
||||
"sub": user.username,
|
||||
"mandateId": str(user.mandateId),
|
||||
"userId": str(user.id),
|
||||
"authenticationAuthority": AuthAuthority.MSFT.value
|
||||
# NO mandateId in token - stateless multi-tenant design
|
||||
}
|
||||
|
||||
# Create JWT access token
|
||||
|
|
@ -363,11 +365,11 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
|
||||
# Decode token to get jti for database record
|
||||
from jose import jwt
|
||||
from modules.auth import SECRET_KEY, ALGORITHM
|
||||
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
jti = payload.get("jti")
|
||||
|
||||
# Create JWT token with matching id
|
||||
# MULTI-TENANT: Token model no longer has mandateId field
|
||||
jwt_token_obj = Token(
|
||||
id=jti,
|
||||
userId=user.id,
|
||||
|
|
@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
|||
tokenAccess=jwt_token,
|
||||
tokenType="bearer",
|
||||
expiresAt=jwt_expires_at.timestamp(),
|
||||
createdAt=getUtcTimestamp(),
|
||||
mandateId=str(user.mandateId)
|
||||
createdAt=getUtcTimestamp()
|
||||
# NO mandateId - Token is not mandate-bound
|
||||
)
|
||||
|
||||
# Save JWT access token
|
||||
|
|
@ -625,13 +627,17 @@ async def logout(
|
|||
appInterface.logout()
|
||||
|
||||
# Log successful logout
|
||||
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||
try:
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logUserAccess(
|
||||
userId=str(currentUser.id),
|
||||
mandateId=str(currentUser.mandateId),
|
||||
mandateId="system",
|
||||
action="logout",
|
||||
successInfo="microsoft_auth_logout"
|
||||
successInfo="microsoft_auth_logout",
|
||||
ipAddress=request.client.host if request.client else None,
|
||||
userAgent=request.headers.get("user-agent"),
|
||||
success=True
|
||||
)
|
||||
except Exception:
|
||||
# Don't fail if audit logging fails
|
||||
|
|
@ -720,7 +726,6 @@ async def refresh_token(
|
|||
logger.debug(f"Found Microsoft connection: {msft_connection.id}, status={msft_connection.status}")
|
||||
|
||||
# Get a fresh token via TokenManager convenience method
|
||||
from modules.auth import TokenManager
|
||||
current_token = TokenManager().getFreshToken(msft_connection.id)
|
||||
|
||||
if not current_token:
|
||||
|
|
@ -732,7 +737,6 @@ async def refresh_token(
|
|||
|
||||
|
||||
# Always attempt refresh (as per your requirement)
|
||||
from modules.auth import TokenManager
|
||||
token_manager = TokenManager()
|
||||
|
||||
refreshedToken = token_manager.refreshToken(current_token)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta
|
|||
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.datamodels.datamodelUam import User, UserConnection
|
||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
from modules.services import getInterface as getServices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -146,3 +146,108 @@ async def list_sharepoint_folders(
|
|||
detail=f"Error listing SharePoint folders: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{connectionId}/folder-options", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("30/minute")
|
||||
async def getSharepointFolderOptions(
|
||||
request: Request,
|
||||
connectionId: str = Path(..., description="Microsoft connection ID"),
|
||||
siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"),
|
||||
path: Optional[str] = Query(None, description="Folder path within site to browse"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get SharePoint folders formatted as dropdown options.
|
||||
|
||||
Two modes:
|
||||
1. If siteId is not provided: Returns list of sites (for site selection)
|
||||
2. If siteId is provided: Returns folders within that site (optionally at specific path)
|
||||
|
||||
This avoids expensive iteration through all sites and folders.
|
||||
"""
|
||||
try:
|
||||
interface = getInterface(currentUser)
|
||||
|
||||
# Get the connection and verify it belongs to the user
|
||||
connection = _getUserConnection(interface, connectionId, currentUser.id)
|
||||
if not connection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Connection {connectionId} not found or does not belong to user"
|
||||
)
|
||||
|
||||
# Verify it's a Microsoft connection
|
||||
authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
|
||||
if authority.lower() != 'msft':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Connection {connectionId} is not a Microsoft connection"
|
||||
)
|
||||
|
||||
# Initialize services
|
||||
services = getServices(currentUser, None)
|
||||
|
||||
# Set access token on SharePoint service
|
||||
if not services.sharepoint.setAccessTokenFromConnection(connection):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Failed to set SharePoint access token. Connection may be expired or invalid."
|
||||
)
|
||||
|
||||
# Mode 1: Return sites list if no siteId specified
|
||||
if not siteId:
|
||||
sites = await services.sharepoint.discoverSites()
|
||||
return [
|
||||
{
|
||||
"type": "site",
|
||||
"value": site.get("id"),
|
||||
"label": site.get("displayName", "Unknown Site"),
|
||||
"siteId": site.get("id"),
|
||||
"siteName": site.get("displayName", "Unknown Site"),
|
||||
"webUrl": site.get("webUrl", ""),
|
||||
"path": _extractSitePath(site.get("webUrl", ""))
|
||||
}
|
||||
for site in sites
|
||||
]
|
||||
|
||||
# Mode 2: Return folders within specific site
|
||||
folderPath = path or ""
|
||||
items = await services.sharepoint.listFolderContents(siteId, folderPath)
|
||||
|
||||
if not items:
|
||||
return []
|
||||
|
||||
folderOptions = []
|
||||
for item in items:
|
||||
if item.get("type") == "folder":
|
||||
folderName = item.get("name", "")
|
||||
itemPath = f"{folderPath}/{folderName}" if folderPath else folderName
|
||||
|
||||
folderOptions.append({
|
||||
"type": "folder",
|
||||
"value": itemPath,
|
||||
"label": folderName,
|
||||
"siteId": siteId,
|
||||
"folderName": folderName,
|
||||
"path": itemPath,
|
||||
"hasChildren": True # Assume folders may have children
|
||||
})
|
||||
|
||||
return folderOptions
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SharePoint folder options: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error getting SharePoint folder options: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _extractSitePath(webUrl: str) -> str:
|
||||
"""Extract site path from webUrl (e.g., https://company.sharepoint.com/sites/MySite -> /sites/MySite)"""
|
||||
if "/sites/" in webUrl:
|
||||
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
|
||||
return ""
|
||||
|
||||
|
|
|
|||
515
modules/routes/routeSystem.py
Normal file
515
modules/routes/routeSystem.py
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
System Routes - Navigation and system-level API endpoints.
|
||||
|
||||
Navigation API Konzept:
|
||||
- Single Source of Truth für Navigation im Gateway
|
||||
- UI rendert nur was es erhält (keine Permission-Logik im UI)
|
||||
- Keine Icons in API Response - UI mappt selbst via uiComponent
|
||||
- Blocks statt Sections mit order auf allen Ebenen
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from fastapi import APIRouter, Depends, Request, Query
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from modules.auth.authentication import getRequestContext, RequestContext
|
||||
from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Main system router (for other system endpoints if needed)
|
||||
router = APIRouter(prefix="/api/system", tags=["System"])
|
||||
|
||||
# Navigation router at /api/navigation (gemäss Navigation-API-Konzept)
|
||||
navigationRouter = APIRouter(prefix="/api", tags=["Navigation"])
|
||||
|
||||
|
||||
def _getUserRoleIds(userId: str) -> List[str]:
|
||||
"""Get all role IDs for a user across all their mandates."""
|
||||
rootInterface = getRootInterface()
|
||||
roleIds = []
|
||||
|
||||
userMandates = rootInterface.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "enabled": True}
|
||||
)
|
||||
|
||||
for um in userMandates:
|
||||
mandateRoleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
|
||||
for rid in mandateRoleIds:
|
||||
if rid not in roleIds:
|
||||
roleIds.append(rid)
|
||||
|
||||
return roleIds
|
||||
|
||||
|
||||
def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
|
||||
"""Check if any of the given roles has view permission for the UI object."""
|
||||
if not roleIds:
|
||||
return False
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
|
||||
for roleId in roleIds:
|
||||
# Get UI rules for this role
|
||||
rules = rootInterface.db.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": roleId, "context": "UI"}
|
||||
)
|
||||
|
||||
for rule in rules:
|
||||
ruleItem = rule.get("item")
|
||||
ruleView = rule.get("view", False)
|
||||
|
||||
if not ruleView:
|
||||
continue
|
||||
|
||||
# Global rule (item=None) grants access to all UI
|
||||
if ruleItem is None:
|
||||
return True
|
||||
|
||||
# Exact match
|
||||
if ruleItem == objectKey:
|
||||
return True
|
||||
|
||||
# Wildcard match (e.g., ui.system.* matches ui.system.playground)
|
||||
if ruleItem.endswith(".*"):
|
||||
prefix = ruleItem[:-2]
|
||||
if objectKey.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Navigation API (gemäss Navigation-API-Konzept)
|
||||
# Endpoint: GET /api/navigation
|
||||
# =============================================================================
|
||||
|
||||
def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get UI objects for a feature from its main module.
|
||||
Returns list of UI objects with objectKey, label, meta (including path).
|
||||
"""
|
||||
try:
|
||||
# Dynamic import based on feature code
|
||||
if featureCode == "trustee":
|
||||
from modules.features.trustee.mainTrustee import UI_OBJECTS
|
||||
return UI_OBJECTS
|
||||
elif featureCode == "realestate":
|
||||
from modules.features.realestate.mainRealEstate import UI_OBJECTS
|
||||
return UI_OBJECTS
|
||||
else:
|
||||
logger.warning(f"Unknown feature code: {featureCode}")
|
||||
return []
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import UI_OBJECTS for feature {featureCode}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _buildDynamicBlock(
|
||||
userId: str,
|
||||
language: str,
|
||||
isSysAdmin: bool
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Build the dynamic features block with mandates, features, and instances.
|
||||
|
||||
Returns None if user has no feature instances.
|
||||
"""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
featureInterface = getFeatureInterface(rootInterface.db)
|
||||
|
||||
# Get all feature accesses for this user
|
||||
featureAccesses = rootInterface.getFeatureAccessesForUser(userId)
|
||||
|
||||
if not featureAccesses:
|
||||
return None
|
||||
|
||||
# Build hierarchical structure: mandate -> feature -> instances
|
||||
mandatesMap: Dict[str, Dict[str, Any]] = {}
|
||||
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
|
||||
|
||||
mandateOrder = 10
|
||||
for access in featureAccesses:
|
||||
if not access.enabled:
|
||||
continue
|
||||
|
||||
instance = featureInterface.getFeatureInstance(str(access.featureInstanceId))
|
||||
if not instance or not instance.enabled:
|
||||
continue
|
||||
|
||||
# Get mandate info
|
||||
mandateId = str(instance.mandateId)
|
||||
if mandateId not in mandatesMap:
|
||||
mandate = rootInterface.getMandate(mandateId)
|
||||
mandateName = mandate.name if mandate and hasattr(mandate, 'name') else mandateId
|
||||
mandatesMap[mandateId] = {
|
||||
"id": mandateId,
|
||||
"uiLabel": mandateName,
|
||||
"order": mandateOrder,
|
||||
"features": []
|
||||
}
|
||||
mandateOrder += 10
|
||||
|
||||
# Get feature info
|
||||
featureKey = f"{mandateId}_{instance.featureCode}"
|
||||
if featureKey not in featuresMap:
|
||||
feature = featureInterface.getFeature(instance.featureCode)
|
||||
|
||||
# Handle featureLabel - could be a dict or a Pydantic model (TextMultilingual)
|
||||
if feature and hasattr(feature, 'label'):
|
||||
featureLabel = feature.label
|
||||
# Convert Pydantic model to dict if needed
|
||||
if hasattr(featureLabel, 'model_dump'):
|
||||
featureLabel = featureLabel.model_dump()
|
||||
elif hasattr(featureLabel, 'dict'):
|
||||
featureLabel = featureLabel.dict()
|
||||
elif not isinstance(featureLabel, dict):
|
||||
# Fallback: try to access as attributes
|
||||
featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)}
|
||||
else:
|
||||
featureLabel = {"de": instance.featureCode, "en": instance.featureCode}
|
||||
|
||||
featuresMap[featureKey] = {
|
||||
"uiComponent": f"feature.{instance.featureCode}",
|
||||
"uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)),
|
||||
"order": 10,
|
||||
"instances": [],
|
||||
"_mandateId": mandateId,
|
||||
"_featureCode": instance.featureCode
|
||||
}
|
||||
|
||||
# Get user's permissions for this instance to filter views
|
||||
permissions = _getInstanceViewPermissions(rootInterface, userId, str(instance.id), isSysAdmin)
|
||||
|
||||
# Get feature UI objects to build views
|
||||
featureUiObjects = _getFeatureUiObjects(instance.featureCode)
|
||||
|
||||
# Build views for this instance
|
||||
views = []
|
||||
viewOrder = 10
|
||||
for uiObj in featureUiObjects:
|
||||
objectKey = uiObj.get("objectKey", "")
|
||||
# Extract view name from objectKey for path building
|
||||
viewName = objectKey.split(".")[-1] if objectKey else ""
|
||||
|
||||
# Check permission using full objectKey (as per Navigation-API-Konzept)
|
||||
if not isSysAdmin and not permissions.get("_all") and not permissions.get(objectKey, False):
|
||||
continue
|
||||
|
||||
# Skip admin-only views for non-admins
|
||||
meta = uiObj.get("meta", {})
|
||||
if meta.get("admin_only") and not isSysAdmin and not permissions.get("isAdmin", False):
|
||||
continue
|
||||
|
||||
# Build path for this view
|
||||
viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}"
|
||||
|
||||
# Get label in requested language
|
||||
label = uiObj.get("label", {})
|
||||
uiLabel = label.get(language, label.get("en", viewName))
|
||||
|
||||
views.append({
|
||||
"uiComponent": f"page.feature.{instance.featureCode}.{viewName}",
|
||||
"uiLabel": uiLabel,
|
||||
"uiPath": viewPath,
|
||||
"order": viewOrder,
|
||||
"objectKey": objectKey
|
||||
})
|
||||
viewOrder += 10
|
||||
|
||||
# Sort views by order
|
||||
views.sort(key=lambda v: v["order"])
|
||||
|
||||
# Add instance to feature
|
||||
featuresMap[featureKey]["instances"].append({
|
||||
"id": str(instance.id),
|
||||
"uiLabel": instance.label,
|
||||
"order": 10,
|
||||
"views": views
|
||||
})
|
||||
|
||||
# Build final structure
|
||||
for featureKey, featureData in featuresMap.items():
|
||||
mandateId = featureData.pop("_mandateId")
|
||||
featureData.pop("_featureCode")
|
||||
mandatesMap[mandateId]["features"].append(featureData)
|
||||
|
||||
# Sort features within each mandate
|
||||
for mandate in mandatesMap.values():
|
||||
mandate["features"].sort(key=lambda f: f["order"])
|
||||
|
||||
# Convert to list and sort by order
|
||||
mandatesList = list(mandatesMap.values())
|
||||
mandatesList.sort(key=lambda m: m["order"])
|
||||
|
||||
if not mandatesList:
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "dynamic",
|
||||
"id": "features",
|
||||
"title": "MEINE FEATURES",
|
||||
"order": 15, # Between system (10) and workflows (20)
|
||||
"mandates": mandatesList
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error building dynamic block: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _getInstanceViewPermissions(
|
||||
rootInterface,
|
||||
userId: str,
|
||||
instanceId: str,
|
||||
isSysAdmin: bool
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get view permissions for a user in a feature instance.
|
||||
Returns dict with view names as keys and True/False as values.
|
||||
Also includes "_all" if user has global view access.
|
||||
"""
|
||||
if isSysAdmin:
|
||||
return {"_all": True, "isAdmin": True}
|
||||
|
||||
permissions = {"_all": False, "isAdmin": False}
|
||||
|
||||
try:
|
||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
|
||||
|
||||
# Get FeatureAccess for this user and instance
|
||||
featureAccesses = rootInterface.db.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
||||
)
|
||||
|
||||
if not featureAccesses:
|
||||
return permissions
|
||||
|
||||
# Get role IDs via FeatureAccessRole junction table
|
||||
featureAccessId = featureAccesses[0].get("id")
|
||||
featureAccessRoles = rootInterface.db.getRecordset(
|
||||
FeatureAccessRole,
|
||||
recordFilter={"featureAccessId": featureAccessId}
|
||||
)
|
||||
roleIds = [far.get("roleId") for far in featureAccessRoles]
|
||||
|
||||
if not roleIds:
|
||||
return permissions
|
||||
|
||||
# Check if user has admin role
|
||||
for roleId in roleIds:
|
||||
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if roles:
|
||||
roleLabel = roles[0].get("roleLabel", "").lower()
|
||||
if "admin" in roleLabel:
|
||||
permissions["isAdmin"] = True
|
||||
break
|
||||
|
||||
# Get UI permissions from AccessRules
|
||||
# Permissions are stored with full objectKey (e.g., ui.feature.trustee.dashboard)
|
||||
for roleId in roleIds:
|
||||
accessRules = rootInterface.db.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": roleId, "context": "UI"}
|
||||
)
|
||||
|
||||
logger.debug(f"_getInstanceViewPermissions: roleId={roleId}, UI rules count={len(accessRules)}")
|
||||
|
||||
for rule in accessRules:
|
||||
if not rule.get("view", False):
|
||||
continue
|
||||
|
||||
item = rule.get("item")
|
||||
logger.debug(f"_getInstanceViewPermissions: rule item={item}, view={rule.get('view')}")
|
||||
|
||||
if item is None:
|
||||
# item=None means all views
|
||||
permissions["_all"] = True
|
||||
else:
|
||||
# Store full objectKey as per Navigation-API-Konzept
|
||||
permissions[item] = True
|
||||
|
||||
logger.debug(f"_getInstanceViewPermissions: final permissions={permissions}")
|
||||
return permissions
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting instance view permissions: {e}")
|
||||
return permissions
|
||||
|
||||
|
||||
def _buildStaticBlocks(
|
||||
language: str,
|
||||
isSysAdmin: bool,
|
||||
roleIds: List[str],
|
||||
hasGlobalPermission: bool
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build static navigation blocks from NAVIGATION_SECTIONS.
|
||||
|
||||
Returns list of blocks with items filtered by permissions.
|
||||
"""
|
||||
blocks = []
|
||||
|
||||
for section in NAVIGATION_SECTIONS:
|
||||
# Skip admin-only sections for non-admins
|
||||
if section.get("adminOnly") and not isSysAdmin:
|
||||
continue
|
||||
|
||||
# Filter items based on permissions
|
||||
filteredItems = []
|
||||
for item in section.get("items", []):
|
||||
# Skip admin-only items for non-admins
|
||||
if item.get("adminOnly") and not isSysAdmin:
|
||||
continue
|
||||
|
||||
# Public items are always visible
|
||||
if item.get("public"):
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
continue
|
||||
|
||||
# SysAdmin sees everything
|
||||
if isSysAdmin:
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
continue
|
||||
|
||||
# Check permission for this item
|
||||
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
|
||||
# Only include section if it has visible items
|
||||
if filteredItems:
|
||||
# Sort items by order
|
||||
filteredItems.sort(key=lambda i: i["order"])
|
||||
|
||||
blocks.append({
|
||||
"type": "static",
|
||||
"id": section["id"],
|
||||
"title": section["title"].get(language, section["title"].get("en", section["id"])),
|
||||
"order": section.get("order", 50),
|
||||
"items": filteredItems,
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a navigation item for the new API response.
|
||||
|
||||
Uses new field names: uiComponent, uiLabel, uiPath
|
||||
Does NOT include icon (UI maps via uiComponent)
|
||||
"""
|
||||
objectKey = item["objectKey"]
|
||||
uiComponent = _objectKeyToUiComponent(objectKey)
|
||||
|
||||
return {
|
||||
"uiComponent": uiComponent,
|
||||
"uiLabel": item["label"].get(language, item["label"].get("en", item["id"])),
|
||||
"uiPath": item["path"],
|
||||
"order": item.get("order", 50),
|
||||
"objectKey": objectKey,
|
||||
}
|
||||
|
||||
|
||||
@navigationRouter.get("/navigation")
|
||||
@limiter.limit("60/minute")
|
||||
async def get_navigation(
|
||||
request: Request,
|
||||
language: str = Query("de", description="Language for labels (en, de, fr)"),
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get unified navigation structure with blocks.
|
||||
|
||||
Single Source of Truth für Navigation - UI rendert nur was es erhält.
|
||||
|
||||
Endpoint: GET /api/navigation
|
||||
|
||||
Block order:
|
||||
- System (10)
|
||||
- Dynamic/Features (15) - only if user has feature instances
|
||||
- Workflows (20)
|
||||
- Basisdaten (30)
|
||||
- Migrate (40)
|
||||
- Administration (200)
|
||||
|
||||
Response format:
|
||||
{
|
||||
"language": "de",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "static",
|
||||
"id": "system",
|
||||
"title": "SYSTEM",
|
||||
"order": 10,
|
||||
"items": [
|
||||
{
|
||||
"uiComponent": "page.system.home",
|
||||
"uiLabel": "Übersicht",
|
||||
"uiPath": "/",
|
||||
"order": 10,
|
||||
"objectKey": "ui.system.home"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "dynamic",
|
||||
"id": "features",
|
||||
"title": "MEINE FEATURES",
|
||||
"order": 15,
|
||||
"mandates": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
isSysAdmin = reqContext.isSysAdmin
|
||||
userId = str(reqContext.user.id) if reqContext.user else None
|
||||
|
||||
# Get user's role IDs for permission checking
|
||||
roleIds = []
|
||||
if userId and not isSysAdmin:
|
||||
roleIds = _getUserRoleIds(userId)
|
||||
|
||||
# Check if user has global UI permission
|
||||
hasGlobalPermission = isSysAdmin
|
||||
if not hasGlobalPermission and roleIds:
|
||||
hasGlobalPermission = _checkUiPermission(roleIds, "_global_check")
|
||||
|
||||
# Build static blocks from NAVIGATION_SECTIONS
|
||||
blocks = _buildStaticBlocks(language, isSysAdmin, roleIds, hasGlobalPermission)
|
||||
|
||||
# Build dynamic block (features) if user has feature instances
|
||||
if userId:
|
||||
dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin)
|
||||
if dynamicBlock:
|
||||
blocks.append(dynamicBlock)
|
||||
|
||||
# Sort all blocks by order
|
||||
blocks.sort(key=lambda b: b["order"])
|
||||
|
||||
return {
|
||||
"language": language,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting navigation: {e}")
|
||||
return {
|
||||
"language": language,
|
||||
"blocks": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ class ConnectionManager:
|
|||
del activeConnections[connectionId]
|
||||
logger.info(f"WebSocket disconnected: {connectionId}")
|
||||
|
||||
async def sendPersonalMessage(self, message: dict, websocket: WebSocket):
|
||||
async def send_personal_message(self, message: dict, websocket: WebSocket):
|
||||
try:
|
||||
await websocket.send_text(json.dumps(message))
|
||||
except Exception as e:
|
||||
|
|
|
|||
54
modules/security/passwordUtils.py
Normal file
54
modules/security/passwordUtils.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Password utility functions for secure password handling.
|
||||
Uses Argon2 for password hashing.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from passlib.context import CryptContext
|
||||
|
||||
# Password hashing context using Argon2
|
||||
_pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def hashPassword(password: str) -> str:
|
||||
"""
|
||||
Hash a password using Argon2.
|
||||
|
||||
Args:
|
||||
password: Plain text password to hash
|
||||
|
||||
Returns:
|
||||
Hashed password string
|
||||
"""
|
||||
return _pwdContext.hash(password)
|
||||
|
||||
|
||||
def verifyPassword(plainPassword: str, hashedPassword: str) -> bool:
|
||||
"""
|
||||
Verify a plain password against a hashed password.
|
||||
|
||||
Args:
|
||||
plainPassword: Plain text password to verify
|
||||
hashedPassword: Hashed password to compare against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
return _pwdContext.verify(plainPassword, hashedPassword)
|
||||
|
||||
|
||||
def getPasswordHash(password: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Hash a password, returning None if password is None.
|
||||
|
||||
Args:
|
||||
password: Plain text password or None
|
||||
|
||||
Returns:
|
||||
Hashed password or None if input was None
|
||||
"""
|
||||
if password is None:
|
||||
return None
|
||||
return _pwdContext.hash(password)
|
||||
|
|
@ -2,14 +2,24 @@
|
|||
# All rights reserved.
|
||||
"""
|
||||
RBAC interface: Core RBAC logic and permission resolution.
|
||||
Moved from interfaces to security module to maintain proper architectural layering.
|
||||
Connectors can import from security, but not from interfaces.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
||||
- Rollen werden über UserMandate + UserMandateRole geladen
|
||||
- Priorisierung: Instance > Mandate > Global
|
||||
- Stateless Design: Kein Cache, direkt aus DB
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
|
||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel, Mandate
|
||||
from modules.datamodels.datamodelMembership import (
|
||||
UserMandate,
|
||||
UserMandateRole,
|
||||
FeatureAccess,
|
||||
FeatureAccessRole
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
|
|
@ -20,6 +30,11 @@ logger = logging.getLogger(__name__)
|
|||
class RbacClass:
|
||||
"""
|
||||
RBAC interface for permission resolution and rule validation.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Lädt Rollen über UserMandate + UserMandateRole
|
||||
- AccessRules werden über roleId gefunden
|
||||
- isSysAdmin für System-Level Operationen (ohne Mandant)
|
||||
"""
|
||||
|
||||
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
|
||||
|
|
@ -34,14 +49,27 @@ class RbacClass:
|
|||
self.db = db
|
||||
self.dbApp = dbApp
|
||||
|
||||
def getUserPermissions(self, user: User, context: AccessRuleContext, item: str) -> UserPermissions:
|
||||
def getUserPermissions(
|
||||
self,
|
||||
user: User,
|
||||
context: AccessRuleContext,
|
||||
item: str,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> UserPermissions:
|
||||
"""
|
||||
Get combined permissions for a user across all their roles.
|
||||
|
||||
Multi-Tenant Design:
|
||||
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
|
||||
- isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
|
||||
|
||||
Args:
|
||||
user: User object with roleLabels
|
||||
user: User object
|
||||
context: Access rule context (DATA, UI, RESOURCE)
|
||||
item: Item identifier (table name, UI path, resource path)
|
||||
mandateId: Optional mandate context for role lookup
|
||||
featureInstanceId: Optional feature instance context
|
||||
|
||||
Returns:
|
||||
UserPermissions object with combined permissions
|
||||
|
|
@ -54,29 +82,51 @@ class RbacClass:
|
|||
delete=AccessLevel.NONE
|
||||
)
|
||||
|
||||
if not hasattr(user, 'roleLabels') or not user.roleLabels:
|
||||
# SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
|
||||
if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
|
||||
return UserPermissions(
|
||||
view=True,
|
||||
read=AccessLevel.ALL,
|
||||
create=AccessLevel.ALL,
|
||||
update=AccessLevel.ALL,
|
||||
delete=AccessLevel.ALL
|
||||
)
|
||||
|
||||
# Lade Role-IDs für den User via UserMandate + UserMandateRole
|
||||
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
|
||||
|
||||
if not roleIds:
|
||||
return permissions
|
||||
|
||||
# Step 1: For each role, find the most specific matching rule (most specific wins within role)
|
||||
rolePermissions = {}
|
||||
for roleLabel in user.roleLabels:
|
||||
# Get all rules for this role and context
|
||||
allRules = self._getRulesForRole(roleLabel, context)
|
||||
|
||||
# Find most specific rule for this item (longest matching prefix)
|
||||
mostSpecificRule = self.findMostSpecificRule(allRules, item)
|
||||
|
||||
if mostSpecificRule:
|
||||
rolePermissions[roleLabel] = mostSpecificRule
|
||||
# Lade alle relevanten Regeln für alle Rollen
|
||||
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
|
||||
|
||||
# Step 2: Combine permissions across roles using opening (union) logic
|
||||
for roleLabel, rule in rolePermissions.items():
|
||||
# Für jede Rolle die spezifischste Regel finden
|
||||
rolePermissions = {}
|
||||
for priority, rule in allRulesWithPriority:
|
||||
# Find most specific rule for this item
|
||||
if self._ruleMatchesItem(rule, item):
|
||||
roleId = rule.roleId
|
||||
# Speichere mit Priorität (höhere Priorität überschreibt)
|
||||
if roleId not in rolePermissions or priority > rolePermissions[roleId][0]:
|
||||
rolePermissions[roleId] = (priority, rule)
|
||||
|
||||
# Find highest priority among matching rules
|
||||
highestPriority = max((p for p, _ in rolePermissions.values()), default=0)
|
||||
|
||||
# Combine permissions ONLY from rules with highest priority
|
||||
# This ensures instance-specific rules (Priority 3) override global rules (Priority 1)
|
||||
for roleId, (priority, rule) in rolePermissions.items():
|
||||
# Only use rules with highest priority
|
||||
if priority < highestPriority:
|
||||
continue
|
||||
|
||||
# View: union logic - if ANY role has view=true, then view=true
|
||||
if rule.view:
|
||||
permissions.view = True
|
||||
|
||||
if context == AccessRuleContext.DATA:
|
||||
# For DATA context, use most permissive access level across roles
|
||||
# For DATA context, use most permissive access level across roles at same priority
|
||||
if rule.read and self._isMorePermissive(rule.read, permissions.read):
|
||||
permissions.read = rule.read
|
||||
if rule.create and self._isMorePermissive(rule.create, permissions.create):
|
||||
|
|
@ -88,6 +138,301 @@ class RbacClass:
|
|||
|
||||
return permissions
|
||||
|
||||
def _getRoleIdsForUser(
|
||||
self,
|
||||
user: User,
|
||||
mandateId: Optional[str],
|
||||
featureInstanceId: Optional[str]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get all role IDs for a user in the given context.
|
||||
Uses UserMandate + UserMandateRole for the new multi-tenant model.
|
||||
|
||||
Also includes roles from the Root mandate (first mandate) if different
|
||||
from the requested mandate, so system-level permissions are always available.
|
||||
|
||||
Args:
|
||||
user: User object
|
||||
mandateId: Mandate context
|
||||
featureInstanceId: Feature instance context
|
||||
|
||||
Returns:
|
||||
List of role IDs
|
||||
"""
|
||||
roleIds = set() # Use set to avoid duplicates
|
||||
|
||||
try:
|
||||
# Get Root mandate ID (first mandate in system)
|
||||
allMandates = self.dbApp.getRecordset(Mandate)
|
||||
rootMandateId = allMandates[0].get("id") if allMandates else None
|
||||
|
||||
# Collect mandates to check:
|
||||
# - If mandateId provided: current mandate + Root mandate (if different)
|
||||
# - If no mandateId: just Root mandate (for system-level access)
|
||||
mandatesToCheck = []
|
||||
if mandateId:
|
||||
mandatesToCheck.append(mandateId)
|
||||
if rootMandateId and rootMandateId not in mandatesToCheck:
|
||||
mandatesToCheck.append(rootMandateId)
|
||||
|
||||
# Load roles from each mandate
|
||||
for checkMandateId in mandatesToCheck:
|
||||
userMandates = self.dbApp.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": user.id, "mandateId": checkMandateId, "enabled": True}
|
||||
)
|
||||
|
||||
if userMandates:
|
||||
userMandateId = userMandates[0].get("id")
|
||||
|
||||
# Lade UserMandateRoles (Mandate-level roles)
|
||||
userMandateRoles = self.dbApp.getRecordset(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId}
|
||||
)
|
||||
|
||||
foundRoles = [r.get("roleId") for r in userMandateRoles if r.get("roleId")]
|
||||
roleIds.update(foundRoles)
|
||||
|
||||
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
|
||||
if featureInstanceId:
|
||||
featureAccessRecords = self.dbApp.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={
|
||||
"userId": user.id,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"enabled": True
|
||||
}
|
||||
)
|
||||
|
||||
if featureAccessRecords:
|
||||
featureAccessId = featureAccessRecords[0].get("id")
|
||||
|
||||
featureAccessRoles = self.dbApp.getRecordset(
|
||||
FeatureAccessRole,
|
||||
recordFilter={"featureAccessId": featureAccessId}
|
||||
)
|
||||
|
||||
roleIds.update([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading role IDs for user {user.id}: {e}")
|
||||
|
||||
return list(roleIds)
|
||||
|
||||
def getRulesForUserBulk(
|
||||
self,
|
||||
userId: str,
|
||||
mandateId: str,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> List[tuple]:
|
||||
"""
|
||||
Lädt alle relevanten Regeln für einen User in EINEM Query.
|
||||
Stateless: Kein Cache, direkt aus DB.
|
||||
|
||||
Optimiert für Multi-Tenant mit Junction Tables:
|
||||
- Mandant-Rollen via UserMandate → UserMandateRole
|
||||
- Instanz-Rollen via FeatureAccess → FeatureAccessRole
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
mandateId: Mandate context
|
||||
featureInstanceId: Optional feature instance context
|
||||
|
||||
Returns:
|
||||
Liste von (priority, AccessRule) Tupeln
|
||||
"""
|
||||
if not mandateId:
|
||||
return []
|
||||
|
||||
try:
|
||||
conn = self.dbApp.connection
|
||||
roleIds = set()
|
||||
|
||||
# 1. Mandant-Rollen via UserMandate → UserMandateRole (SINGLE Query)
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT umr."roleId"
|
||||
FROM "UserMandate" um
|
||||
JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id
|
||||
WHERE um."userId" = %s AND um."mandateId" = %s AND um."enabled" = true
|
||||
""",
|
||||
(userId, mandateId)
|
||||
)
|
||||
mandateRoles = cursor.fetchall()
|
||||
roleIds.update(r["roleId"] for r in mandateRoles if r.get("roleId"))
|
||||
|
||||
# 2. Instanz-Rollen via FeatureAccess → FeatureAccessRole (SINGLE Query)
|
||||
if featureInstanceId:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT far."roleId"
|
||||
FROM "FeatureAccess" fa
|
||||
JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id
|
||||
WHERE fa."userId" = %s AND fa."featureInstanceId" = %s AND fa."enabled" = true
|
||||
""",
|
||||
(userId, featureInstanceId)
|
||||
)
|
||||
instanceRoles = cursor.fetchall()
|
||||
roleIds.update(r["roleId"] for r in instanceRoles if r.get("roleId"))
|
||||
|
||||
if not roleIds:
|
||||
return []
|
||||
|
||||
# 3. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten
|
||||
# SINGLE Query mit JOIN statt N+1
|
||||
roleIdsList = list(roleIds)
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ar.*, r."mandateId" as "roleMandateId",
|
||||
r."featureInstanceId" as "roleInstanceId"
|
||||
FROM "AccessRule" ar
|
||||
JOIN "Role" r ON ar."roleId" = r.id
|
||||
WHERE ar."roleId" = ANY(%s)
|
||||
""",
|
||||
(roleIdsList,)
|
||||
)
|
||||
allRulesWithContext = cursor.fetchall()
|
||||
|
||||
# 4. Priorität zuweisen basierend auf Role-Scope
|
||||
rulesWithPriority = []
|
||||
for ruleRecord in allRulesWithContext:
|
||||
ruleDict = dict(ruleRecord)
|
||||
|
||||
# Bestimme Priorität
|
||||
if ruleDict.get("roleInstanceId"):
|
||||
priority = 3 # Instance-Rolle = höchste Priorität
|
||||
elif ruleDict.get("roleMandateId"):
|
||||
priority = 2 # Mandate-Rolle
|
||||
else:
|
||||
priority = 1 # Global-Rolle = niedrigste Priorität
|
||||
|
||||
# Entferne Hilfsspalten vor AccessRule-Erstellung
|
||||
ruleDict.pop("roleMandateId", None)
|
||||
ruleDict.pop("roleInstanceId", None)
|
||||
|
||||
try:
|
||||
rule = AccessRule(**ruleDict)
|
||||
rulesWithPriority.append((priority, rule))
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting rule record: {e}")
|
||||
|
||||
return rulesWithPriority
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in getRulesForUserBulk: {e}")
|
||||
return []
|
||||
|
||||
def _getRulesForRoleIds(
|
||||
self,
|
||||
roleIds: List[str],
|
||||
context: AccessRuleContext,
|
||||
mandateId: Optional[str],
|
||||
featureInstanceId: Optional[str]
|
||||
) -> List[tuple]:
|
||||
"""
|
||||
Get all access rules for the given role IDs with priority.
|
||||
|
||||
Priority:
|
||||
- 3: Instance-specific role (featureInstanceId set)
|
||||
- 2: Mandate-specific role (mandateId set, no featureInstanceId)
|
||||
- 1: Global role (no mandateId)
|
||||
|
||||
Args:
|
||||
roleIds: List of role IDs
|
||||
context: Access rule context
|
||||
mandateId: Current mandate context
|
||||
featureInstanceId: Current feature instance context
|
||||
|
||||
Returns:
|
||||
List of (priority, AccessRule) tuples
|
||||
"""
|
||||
rulesWithPriority = []
|
||||
|
||||
if not roleIds:
|
||||
return rulesWithPriority
|
||||
|
||||
try:
|
||||
# Lade alle Regeln für alle Rollen
|
||||
for roleId in roleIds:
|
||||
rules = self.dbApp.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleId": roleId, "context": context.value}
|
||||
)
|
||||
|
||||
# Lade Role um Priorität zu bestimmen
|
||||
roleRecords = self.dbApp.getRecordset(Role, recordFilter={"id": roleId})
|
||||
if not roleRecords:
|
||||
continue
|
||||
|
||||
role = roleRecords[0]
|
||||
|
||||
# Bestimme Priorität basierend auf Role-Scope
|
||||
if role.get("featureInstanceId"):
|
||||
priority = 3 # Instance-specific
|
||||
elif role.get("mandateId"):
|
||||
priority = 2 # Mandate-specific
|
||||
else:
|
||||
priority = 1 # Global
|
||||
|
||||
for ruleRecord in rules:
|
||||
try:
|
||||
rule = AccessRule(**ruleRecord)
|
||||
rulesWithPriority.append((priority, rule))
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting rule record: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading rules for role IDs: {e}")
|
||||
|
||||
return rulesWithPriority
|
||||
|
||||
def _ruleMatchesItem(self, rule: AccessRule, item: str) -> bool:
|
||||
"""
|
||||
Check if a rule matches the given item.
|
||||
|
||||
Matching rules (in order of specificity):
|
||||
1. Generic rule (item=None) matches everything
|
||||
2. Exact match (rule.item == item)
|
||||
3. Prefix match (item starts with rule.item + ".")
|
||||
Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition"
|
||||
|
||||
All items MUST use the full objectKey format:
|
||||
- UAM: data.uam.{TableName} (e.g., "data.uam.UserInDB")
|
||||
- Chat: data.chat.{TableName} (e.g., "data.chat.ChatWorkflow")
|
||||
- Files: data.files.{TableName} (e.g., "data.files.FileItem")
|
||||
- Automation: data.automation.{TableName} (e.g., "data.automation.AutomationDefinition")
|
||||
- Feature: data.feature.{featureCode}.{TableName} (e.g., "data.feature.trustee.TrusteePosition")
|
||||
- UI: ui.{area}.{page} (e.g., "ui.admin.users")
|
||||
|
||||
Args:
|
||||
rule: Access rule to check
|
||||
item: Full objectKey to match against
|
||||
|
||||
Returns:
|
||||
True if rule matches item
|
||||
"""
|
||||
if rule.item is None:
|
||||
# Generic rule matches everything
|
||||
return True
|
||||
|
||||
if not item:
|
||||
# No item specified, only generic rules match
|
||||
return rule.item is None
|
||||
|
||||
# Exact match
|
||||
if rule.item == item:
|
||||
return True
|
||||
|
||||
# Prefix match (e.g., "data.feature.trustee" matches "data.feature.trustee.TrusteePosition")
|
||||
if item.startswith(rule.item + "."):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]:
|
||||
"""
|
||||
Find the most specific rule for an item (longest matching prefix wins).
|
||||
|
|
@ -105,7 +450,6 @@ class RbacClass:
|
|||
return genericRules[0] if genericRules else None
|
||||
|
||||
# Find longest matching prefix
|
||||
itemParts = item.split(".")
|
||||
bestMatch = None
|
||||
bestMatchLength = -1
|
||||
|
||||
|
|
@ -176,39 +520,3 @@ class RbacClass:
|
|||
AccessLevel.ALL: 3
|
||||
}
|
||||
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
|
||||
|
||||
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext) -> List[AccessRule]:
|
||||
"""
|
||||
Get all access rules for a specific role and context.
|
||||
Always queries from DbApp database, not the current database.
|
||||
|
||||
Args:
|
||||
roleLabel: Role label to get rules for
|
||||
context: Context type
|
||||
|
||||
Returns:
|
||||
List of AccessRule objects
|
||||
"""
|
||||
try:
|
||||
# Always use DbApp database for AccessRule queries
|
||||
rules = self.dbApp.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={
|
||||
"roleLabel": roleLabel,
|
||||
"context": context.value
|
||||
}
|
||||
)
|
||||
|
||||
# Convert dict records to AccessRule objects
|
||||
accessRules = []
|
||||
for record in rules:
|
||||
try:
|
||||
accessRule = AccessRule(**record)
|
||||
accessRules.append(accessRule)
|
||||
except Exception as e:
|
||||
logger.error(f"Error converting rule record to AccessRule: {e}, record={record}")
|
||||
|
||||
return accessRules
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting rules for role {roleLabel} and context {context.value}: {e}", exc_info=True)
|
||||
return []
|
||||
|
|
|
|||
192
modules/security/rbacCatalog.py
Normal file
192
modules/security/rbacCatalog.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
RBAC Catalog Service.
|
||||
Central registry for Feature RBAC objects (UI and RESOURCE).
|
||||
|
||||
Feature-Container register their RBAC objects via mainXxx.py at startup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional
|
||||
from threading import Lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RbacCatalogService:
|
||||
"""
|
||||
Central RBAC Catalog for Feature UI and RESOURCE objects.
|
||||
Singleton service that stores all registered RBAC objects from feature containers.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._uiObjects: Dict[str, Dict[str, Any]] = {}
|
||||
self._resourceObjects: Dict[str, Dict[str, Any]] = {}
|
||||
self._dataObjects: Dict[str, Dict[str, Any]] = {} # DATA objects (tables/entities)
|
||||
self._featureDefinitions: Dict[str, Dict[str, Any]] = {}
|
||||
self._templateRoles: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._initialized = True
|
||||
logger.info("RBAC Catalog Service initialized")
|
||||
|
||||
def registerUiObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Register a UI object for a feature."""
|
||||
try:
|
||||
self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"}
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register UI object {objectKey}: {e}")
|
||||
return False
|
||||
|
||||
def registerResourceObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Register a RESOURCE object for a feature."""
|
||||
try:
|
||||
self._resourceObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "RESOURCE"}
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register RESOURCE object {objectKey}: {e}")
|
||||
return False
|
||||
|
||||
def registerDataObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""
|
||||
Register a DATA object (table/entity) for a feature.
|
||||
|
||||
Args:
|
||||
featureCode: Feature code (e.g., "trustee", "system")
|
||||
objectKey: Dot-notation key (e.g., "data.feature.trustee.TrusteeContract")
|
||||
label: Multilingual label dict
|
||||
meta: Optional metadata (e.g., table name, fields list)
|
||||
"""
|
||||
try:
|
||||
self._dataObjects[objectKey] = {
|
||||
"objectKey": objectKey,
|
||||
"featureCode": featureCode,
|
||||
"label": label,
|
||||
"meta": meta or {},
|
||||
"type": "DATA"
|
||||
}
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register DATA object {objectKey}: {e}")
|
||||
return False
|
||||
|
||||
def registerFeatureDefinition(self, featureCode: str, label: Dict[str, str], icon: str) -> bool:
|
||||
"""Register a feature definition."""
|
||||
try:
|
||||
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register feature definition {featureCode}: {e}")
|
||||
return False
|
||||
|
||||
def registerTemplateRoles(self, featureCode: str, roles: List[Dict[str, Any]]) -> bool:
|
||||
"""Register template roles for a feature."""
|
||||
try:
|
||||
self._templateRoles[featureCode] = roles
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register template roles for {featureCode}: {e}")
|
||||
return False
|
||||
|
||||
def getUiObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all UI objects, optionally filtered by feature."""
|
||||
if featureCode:
|
||||
return [obj for obj in self._uiObjects.values() if obj["featureCode"] == featureCode]
|
||||
return list(self._uiObjects.values())
|
||||
|
||||
def getResourceObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all RESOURCE objects, optionally filtered by feature."""
|
||||
if featureCode:
|
||||
return [obj for obj in self._resourceObjects.values() if obj["featureCode"] == featureCode]
|
||||
return list(self._resourceObjects.values())
|
||||
|
||||
def getDataObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all DATA objects (tables/entities), optionally filtered by feature."""
|
||||
if featureCode:
|
||||
return [obj for obj in self._dataObjects.values() if obj["featureCode"] == featureCode]
|
||||
return list(self._dataObjects.values())
|
||||
|
||||
def getAllObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get all RBAC objects (UI + RESOURCE + DATA), optionally filtered by feature."""
|
||||
return self.getUiObjects(featureCode) + self.getResourceObjects(featureCode) + self.getDataObjects(featureCode)
|
||||
|
||||
def getAllCatalogObjects(self, featureCode: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get all catalog objects grouped by type (DATA, UI, RESOURCE)."""
|
||||
return {
|
||||
"DATA": self.getDataObjects(featureCode),
|
||||
"UI": self.getUiObjects(featureCode),
|
||||
"RESOURCE": self.getResourceObjects(featureCode)
|
||||
}
|
||||
|
||||
def getFeatureDefinitions(self) -> List[Dict[str, Any]]:
|
||||
"""Get all registered feature definitions."""
|
||||
return list(self._featureDefinitions.values())
|
||||
|
||||
def getFeatureDefinition(self, featureCode: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific feature definition."""
|
||||
return self._featureDefinitions.get(featureCode)
|
||||
|
||||
def getTemplateRoles(self, featureCode: str) -> List[Dict[str, Any]]:
|
||||
"""Get template roles for a feature."""
|
||||
return self._templateRoles.get(featureCode, [])
|
||||
|
||||
def getAllTemplateRoles(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get all template roles grouped by feature."""
|
||||
return self._templateRoles.copy()
|
||||
|
||||
def getRegisteredFeatures(self) -> List[str]:
|
||||
"""Get list of all registered feature codes."""
|
||||
return list(self._featureDefinitions.keys())
|
||||
|
||||
def unregisterFeature(self, featureCode: str) -> bool:
|
||||
"""Unregister all objects for a feature."""
|
||||
try:
|
||||
for key in [k for k, v in self._uiObjects.items() if v["featureCode"] == featureCode]:
|
||||
del self._uiObjects[key]
|
||||
for key in [k for k, v in self._resourceObjects.items() if v["featureCode"] == featureCode]:
|
||||
del self._resourceObjects[key]
|
||||
for key in [k for k, v in self._dataObjects.items() if v["featureCode"] == featureCode]:
|
||||
del self._dataObjects[key]
|
||||
self._featureDefinitions.pop(featureCode, None)
|
||||
self._templateRoles.pop(featureCode, None)
|
||||
logger.info(f"Unregistered feature: {featureCode}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to unregister feature {featureCode}: {e}")
|
||||
return False
|
||||
|
||||
def getCatalogStats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about the catalog."""
|
||||
return {
|
||||
"features": len(self._featureDefinitions),
|
||||
"uiObjects": len(self._uiObjects),
|
||||
"resourceObjects": len(self._resourceObjects),
|
||||
"dataObjects": len(self._dataObjects),
|
||||
"templateRoles": sum(len(roles) for roles in self._templateRoles.values())
|
||||
}
|
||||
|
||||
|
||||
# Singleton accessor
|
||||
_catalogService: Optional[RbacCatalogService] = None
|
||||
|
||||
|
||||
def getCatalogService() -> RbacCatalogService:
|
||||
"""Get the singleton RBAC Catalog Service instance."""
|
||||
global _catalogService
|
||||
if _catalogService is None:
|
||||
_catalogService = RbacCatalogService()
|
||||
return _catalogService
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
"""
|
||||
Root access management for system-level operations.
|
||||
Provides secure access to root user and DbApp database connector.
|
||||
|
||||
Bei leerer Datenbank wird automatisch Bootstrap ausgeführt.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -14,6 +16,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_rootDbAppConnector = None
|
||||
_rootUser = None
|
||||
_bootstrapExecuted = False
|
||||
|
||||
def getRootDbAppConnector() -> DatabaseConnector:
|
||||
"""
|
||||
|
|
@ -24,29 +27,62 @@ def getRootDbAppConnector() -> DatabaseConnector:
|
|||
|
||||
if _rootDbAppConnector is None:
|
||||
_rootDbAppConnector = DatabaseConnector(
|
||||
dbHost=APP_CONFIG.get("DB_APP_HOST"),
|
||||
dbDatabase=APP_CONFIG.get("DB_APP_DATABASE", "app"),
|
||||
dbUser=APP_CONFIG.get("DB_APP_USER"),
|
||||
dbPassword=APP_CONFIG.get("DB_APP_PASSWORD_SECRET"),
|
||||
dbPort=int(APP_CONFIG.get("DB_APP_PORT", 5432)),
|
||||
dbHost=APP_CONFIG.get("DB_HOST"),
|
||||
dbDatabase="poweron_app",
|
||||
dbUser=APP_CONFIG.get("DB_USER"),
|
||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||
userId=None # No user context for root connector
|
||||
)
|
||||
_rootDbAppConnector.initDbSystem()
|
||||
|
||||
return _rootDbAppConnector
|
||||
|
||||
|
||||
def _ensureBootstrap():
|
||||
"""
|
||||
Führt Bootstrap aus, falls noch nicht geschehen.
|
||||
Wird automatisch aufgerufen, wenn getRootUser() keinen User findet.
|
||||
"""
|
||||
global _bootstrapExecuted
|
||||
|
||||
if _bootstrapExecuted:
|
||||
return
|
||||
|
||||
logger.info("Running bootstrap to initialize database")
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from modules.interfaces.interfaceBootstrap import initBootstrap
|
||||
|
||||
dbApp = getRootDbAppConnector()
|
||||
initBootstrap(dbApp)
|
||||
|
||||
_bootstrapExecuted = True
|
||||
logger.info("Bootstrap completed")
|
||||
|
||||
|
||||
def getRootUser() -> User:
|
||||
"""
|
||||
Returns the root user (initial user from database).
|
||||
Used for system-level operations that require root privileges.
|
||||
|
||||
Falls kein User existiert, wird Bootstrap automatisch ausgeführt.
|
||||
"""
|
||||
global _rootUser
|
||||
|
||||
if _rootUser is None:
|
||||
dbApp = getRootDbAppConnector()
|
||||
initialUserId = dbApp.getInitialId(UserInDB)
|
||||
|
||||
# Wenn kein User existiert, Bootstrap ausführen
|
||||
if not initialUserId:
|
||||
raise ValueError("No initial user ID found in database")
|
||||
logger.info("No initial user found, running bootstrap")
|
||||
_ensureBootstrap()
|
||||
|
||||
# Nochmal versuchen nach Bootstrap
|
||||
initialUserId = dbApp.getInitialId(UserInDB)
|
||||
if not initialUserId:
|
||||
raise ValueError("No initial user ID found in database after bootstrap")
|
||||
|
||||
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
|
||||
if not users:
|
||||
|
|
@ -56,4 +92,3 @@ def getRootUser() -> User:
|
|||
_rootUser = User(**user_data)
|
||||
|
||||
return _rootUser
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue