gateway/modules/routes/routeNotifications.py
2026-01-26 12:39:00 +01:00

587 lines
19 KiB
Python

# 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)}"
)