mandate invitation and notification system
This commit is contained in:
parent
e737bf5cdb
commit
a0304c6d78
9 changed files with 1072 additions and 48 deletions
3
app.py
3
app.py
|
|
@ -492,6 +492,9 @@ app.include_router(featuresAdminRouter)
|
||||||
from modules.routes.routeInvitations import router as invitationsRouter
|
from modules.routes.routeInvitations import router as invitationsRouter
|
||||||
app.include_router(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
|
from modules.routes.routeAdminRbacExport import router as rbacAdminExportRouter
|
||||||
app.include_router(rbacAdminExportRouter)
|
app.include_router(rbacAdminExportRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
def _get_model_fields(model_class) -> Dict[str, str]:
|
||||||
"""Get all fields from Pydantic model and map to SQL types."""
|
"""Get all fields from Pydantic model and map to SQL types."""
|
||||||
# Pydantic v2
|
# Pydantic v2
|
||||||
|
|
@ -52,20 +80,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
||||||
|
|
||||||
# Check for JSONB fields (Dict, List, or complex types)
|
# Check for JSONB fields (Dict, List, or complex types)
|
||||||
# Purely type-based detection - no hardcoded field names
|
# Purely type-based detection - no hardcoded field names
|
||||||
if (
|
if _isJsonbType(field_type):
|
||||||
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)))
|
|
||||||
):
|
|
||||||
fields[field_name] = "JSONB"
|
fields[field_name] = "JSONB"
|
||||||
# Simple type mapping
|
# Simple type mapping
|
||||||
elif field_type in (str, type(None)) or (
|
elif field_type in (str, type(None)) or (
|
||||||
|
|
@ -970,7 +985,10 @@ class DatabaseConnector:
|
||||||
record["id"] = str(uuid.uuid4())
|
record["id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
# Save record
|
# 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
|
# Check if this is the first record in the table and register as initial ID
|
||||||
table = model_class.__name__
|
table = model_class.__name__
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,13 @@ class Invitation(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Einladungs-Details
|
# 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(
|
email: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Target email address (optional, for tracking)",
|
description="Email address to send invitation link (optional)",
|
||||||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
createdBy: str = Field(
|
createdBy: str = Field(
|
||||||
|
|
@ -82,6 +86,13 @@ class Invitation(BaseModel):
|
||||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
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
|
# Einschränkungen
|
||||||
maxUses: int = Field(
|
maxUses: int = Field(
|
||||||
default=1,
|
default=1,
|
||||||
|
|
@ -107,13 +118,15 @@ registerModelLabels(
|
||||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||||
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
||||||
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
|
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
|
||||||
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
|
"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"},
|
"createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||||
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
||||||
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
|
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
|
||||||
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
|
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
|
||||||
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué 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"},
|
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
|
||||||
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
|
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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é"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -21,6 +21,7 @@ from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -72,15 +73,16 @@ async def list_features(
|
||||||
"""
|
"""
|
||||||
List all available features.
|
List all available features.
|
||||||
|
|
||||||
Returns global feature definitions that can be activated for mandates.
|
Returns global feature definitions from the RBAC Catalog.
|
||||||
|
Features are automatically registered at startup from feature containers.
|
||||||
Any authenticated user can see available features.
|
Any authenticated user can see available features.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
# Features come from the RBAC Catalog (registered at startup from feature containers)
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
# NOT from the database - features are code-defined, not user-created
|
||||||
|
catalogService = getCatalogService()
|
||||||
features = featureInterface.getAllFeatures()
|
features = catalogService.getFeatureDefinitions()
|
||||||
return [f.model_dump() for f in features]
|
return features
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing features: {e}")
|
logger.error(f"Error listing features: {e}")
|
||||||
|
|
@ -153,14 +155,15 @@ async def get_my_feature_instances(
|
||||||
"features": []
|
"features": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get feature info
|
# Get feature info from catalog (features are code-defined)
|
||||||
featureKey = f"{mandateId}_{instance.featureCode}"
|
featureKey = f"{mandateId}_{instance.featureCode}"
|
||||||
if featureKey not in featuresMap:
|
if featureKey not in featuresMap:
|
||||||
feature = featureInterface.getFeature(instance.featureCode)
|
catalogService = getCatalogService()
|
||||||
|
featureDef = catalogService.getFeatureDefinition(instance.featureCode)
|
||||||
featuresMap[featureKey] = {
|
featuresMap[featureKey] = {
|
||||||
"code": instance.featureCode,
|
"code": instance.featureCode,
|
||||||
"label": feature.label if feature and hasattr(feature, 'label') else {"de": instance.featureCode, "en": instance.featureCode},
|
"label": featureDef.get("label", {"de": instance.featureCode, "en": instance.featureCode}) if featureDef else {"de": instance.featureCode, "en": instance.featureCode},
|
||||||
"icon": feature.icon if feature and hasattr(feature, 'icon') else "folder",
|
"icon": featureDef.get("icon", "folder") if featureDef else "folder",
|
||||||
"instances": [],
|
"instances": [],
|
||||||
"_mandateId": mandateId # Temporary for grouping
|
"_mandateId": mandateId # Temporary for grouping
|
||||||
}
|
}
|
||||||
|
|
@ -376,8 +379,9 @@ async def create_feature(
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
# Check if feature already exists
|
# Check if feature already exists in catalog (features are code-defined)
|
||||||
existing = featureInterface.getFeature(code)
|
catalogService = getCatalogService()
|
||||||
|
existing = catalogService.getFeatureDefinition(code)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
|
@ -525,9 +529,10 @@ async def create_feature_instance(
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
# Verify feature exists
|
# Verify feature exists in catalog (features are code-defined, not DB-stored)
|
||||||
feature = featureInterface.getFeature(data.featureCode)
|
catalogService = getCatalogService()
|
||||||
if not feature:
|
featureDef = catalogService.getFeatureDefinition(data.featureCode)
|
||||||
|
if not featureDef:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Feature '{data.featureCode}' not found"
|
detail=f"Feature '{data.featureCode}' not found"
|
||||||
|
|
@ -818,9 +823,10 @@ async def create_template_role(
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
# Verify feature exists
|
# Verify feature exists in catalog (features are code-defined)
|
||||||
feature = featureInterface.getFeature(featureCode)
|
catalogService = getCatalogService()
|
||||||
if not feature:
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
||||||
|
if not featureDef:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Feature '{featureCode}' not found"
|
detail=f"Feature '{featureCode}' not found"
|
||||||
|
|
@ -1331,17 +1337,16 @@ async def get_feature(
|
||||||
featureCode: Feature code (e.g., 'trustee', 'chatbot')
|
featureCode: Feature code (e.g., 'trustee', 'chatbot')
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
# Features come from the RBAC Catalog (code-defined, not DB-stored)
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
catalogService = getCatalogService()
|
||||||
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
||||||
feature = featureInterface.getFeature(featureCode)
|
if not featureDef:
|
||||||
if not feature:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Feature '{featureCode}' not found"
|
detail=f"Feature '{featureCode}' not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
return feature.model_dump()
|
return featureDef
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ router = APIRouter(
|
||||||
|
|
||||||
class InvitationCreate(BaseModel):
|
class InvitationCreate(BaseModel):
|
||||||
"""Request model for creating an invitation"""
|
"""Request model for creating an invitation"""
|
||||||
email: Optional[str] = Field(None, description="Target email address (optional)")
|
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")
|
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user")
|
||||||
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
|
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
|
||||||
expiresInHours: int = Field(
|
expiresInHours: int = Field(
|
||||||
|
|
@ -61,6 +62,7 @@ class InvitationResponse(BaseModel):
|
||||||
mandateId: str
|
mandateId: str
|
||||||
featureInstanceId: Optional[str]
|
featureInstanceId: Optional[str]
|
||||||
roleIds: List[str]
|
roleIds: List[str]
|
||||||
|
targetUsername: str
|
||||||
email: Optional[str]
|
email: Optional[str]
|
||||||
createdBy: str
|
createdBy: str
|
||||||
createdAt: float
|
createdAt: float
|
||||||
|
|
@ -71,6 +73,7 @@ class InvitationResponse(BaseModel):
|
||||||
maxUses: int
|
maxUses: int
|
||||||
currentUses: int
|
currentUses: int
|
||||||
inviteUrl: str # Full URL for the invitation
|
inviteUrl: str # Full URL for the invitation
|
||||||
|
emailSent: bool = False # Whether invitation email was sent
|
||||||
|
|
||||||
|
|
||||||
class InvitationValidation(BaseModel):
|
class InvitationValidation(BaseModel):
|
||||||
|
|
@ -78,8 +81,11 @@ class InvitationValidation(BaseModel):
|
||||||
valid: bool
|
valid: bool
|
||||||
reason: Optional[str]
|
reason: Optional[str]
|
||||||
mandateId: Optional[str]
|
mandateId: Optional[str]
|
||||||
|
mandateName: Optional[str] = None
|
||||||
featureInstanceId: Optional[str]
|
featureInstanceId: Optional[str]
|
||||||
roleIds: List[str]
|
roleIds: List[str]
|
||||||
|
roleLabels: List[str] = []
|
||||||
|
targetUsername: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -118,6 +124,11 @@ async def create_invitation(
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
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
|
# Validate role IDs exist and belong to this mandate or are global
|
||||||
for roleId in data.roleIds:
|
for roleId in data.roleIds:
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
@ -164,6 +175,7 @@ async def create_invitation(
|
||||||
mandateId=str(context.mandateId),
|
mandateId=str(context.mandateId),
|
||||||
featureInstanceId=data.featureInstanceId,
|
featureInstanceId=data.featureInstanceId,
|
||||||
roleIds=data.roleIds,
|
roleIds=data.roleIds,
|
||||||
|
targetUsername=data.targetUsername,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
createdBy=str(context.user.id),
|
createdBy=str(context.user.id),
|
||||||
expiresAt=expiresAt,
|
expiresAt=expiresAt,
|
||||||
|
|
@ -179,9 +191,98 @@ async def create_invitation(
|
||||||
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
|
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
|
||||||
inviteUrl = f"{frontendUrl}/invite/{invitation.token}"
|
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(
|
logger.info(
|
||||||
f"User {context.user.id} created invitation for mandate {context.mandateId}, "
|
f"User {context.user.id} created invitation for user {data.targetUsername} "
|
||||||
f"expires in {data.expiresInHours}h"
|
f"to mandate {context.mandateId}, expires in {data.expiresInHours}h"
|
||||||
)
|
)
|
||||||
|
|
||||||
return InvitationResponse(
|
return InvitationResponse(
|
||||||
|
|
@ -190,6 +291,7 @@ async def create_invitation(
|
||||||
mandateId=str(createdRecord.get("mandateId")),
|
mandateId=str(createdRecord.get("mandateId")),
|
||||||
featureInstanceId=createdRecord.get("featureInstanceId"),
|
featureInstanceId=createdRecord.get("featureInstanceId"),
|
||||||
roleIds=createdRecord.get("roleIds", []),
|
roleIds=createdRecord.get("roleIds", []),
|
||||||
|
targetUsername=createdRecord.get("targetUsername"),
|
||||||
email=createdRecord.get("email"),
|
email=createdRecord.get("email"),
|
||||||
createdBy=str(createdRecord.get("createdBy")),
|
createdBy=str(createdRecord.get("createdBy")),
|
||||||
createdAt=createdRecord.get("createdAt"),
|
createdAt=createdRecord.get("createdAt"),
|
||||||
|
|
@ -199,7 +301,8 @@ async def create_invitation(
|
||||||
revokedAt=createdRecord.get("revokedAt"),
|
revokedAt=createdRecord.get("revokedAt"),
|
||||||
maxUses=createdRecord.get("maxUses", 1),
|
maxUses=createdRecord.get("maxUses", 1),
|
||||||
currentUses=createdRecord.get("currentUses", 0),
|
currentUses=createdRecord.get("currentUses", 0),
|
||||||
inviteUrl=inviteUrl
|
inviteUrl=inviteUrl,
|
||||||
|
emailSent=emailSent
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -441,12 +544,38 @@ async def validate_invitation(
|
||||||
roleIds=[]
|
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(
|
return InvitationValidation(
|
||||||
valid=True,
|
valid=True,
|
||||||
reason=None,
|
reason=None,
|
||||||
mandateId=invitation.get("mandateId"),
|
mandateId=mandateId,
|
||||||
|
mandateName=mandateName,
|
||||||
featureInstanceId=invitation.get("featureInstanceId"),
|
featureInstanceId=invitation.get("featureInstanceId"),
|
||||||
roleIds=invitation.get("roleIds", [])
|
roleIds=roleIds,
|
||||||
|
roleLabels=roleLabels,
|
||||||
|
targetUsername=targetUsername
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -513,6 +642,17 @@ async def accept_invitation(
|
||||||
detail="Invitation has reached maximum uses"
|
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")
|
mandateId = invitation.get("mandateId")
|
||||||
roleIds = invitation.get("roleIds", [])
|
roleIds = invitation.get("roleIds", [])
|
||||||
featureInstanceId = invitation.get("featureInstanceId")
|
featureInstanceId = invitation.get("featureInstanceId")
|
||||||
|
|
|
||||||
575
modules/routes/routeNotifications.py
Normal file
575
modules/routes/routeNotifications.py
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
# 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.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", [])
|
||||||
|
|
||||||
|
# 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)}"
|
||||||
|
)
|
||||||
|
|
@ -20,6 +20,7 @@ from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -322,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)}")
|
logger.error(f"Error sending registration email: {str(emailErr)}")
|
||||||
# Don't fail registration if email fails - user can request reset later
|
# 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 {
|
return {
|
||||||
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ def loadFeatureMainModules() -> Dict[str, Any]:
|
||||||
def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
||||||
"""
|
"""
|
||||||
Register all features' RBAC objects in the catalog.
|
Register all features' RBAC objects in the catalog.
|
||||||
Also registers system-level RBAC objects.
|
Also registers system-level RBAC objects and feature definitions.
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
|
|
@ -132,6 +132,20 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]:
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
|
|
||||||
for featureName, module in mainModules.items():
|
for featureName, module in mainModules.items():
|
||||||
|
# Register feature definition in catalog (for /api/features/ endpoint)
|
||||||
|
if hasattr(module, "getFeatureDefinition"):
|
||||||
|
try:
|
||||||
|
featureDef = module.getFeatureDefinition()
|
||||||
|
catalogService.registerFeatureDefinition(
|
||||||
|
featureCode=featureDef.get("code", featureName),
|
||||||
|
label=featureDef.get("label", {"en": featureName, "de": featureName}),
|
||||||
|
icon=featureDef.get("icon", "mdi-puzzle")
|
||||||
|
)
|
||||||
|
logger.info(f"Registered feature definition: {featureDef.get('code', featureName)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error registering feature definition for {featureName}: {e}")
|
||||||
|
|
||||||
|
# Register RBAC objects (UI, RESOURCE, DATA)
|
||||||
if hasattr(module, "registerFeature"):
|
if hasattr(module, "registerFeature"):
|
||||||
try:
|
try:
|
||||||
success = module.registerFeature(catalogService)
|
success = module.registerFeature(catalogService)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue