Files
smart-city-digital-twin-mar…/smart-app-city/backend/app/routes/notifications.py

377 lines
12 KiB
Python

"""Notification routes — list, mark-as-read, device registration, preferences.
All endpoints require JWT authentication (Bearer token).
Design decisions consistent with the existing iot.py and auth.py patterns:
• Dependencies use the shared ``get_current_user`` from app.auth.jwt.
• The dependency returns a JWT payload dict (``{"sub": user_id, "role": ...}``).
• UUID path/query parameters are validated inline.
• Responses follow the same plain-dict style as iot.py.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.database import get_session
from app.models.models import Notification, NotificationDevice, NotificationPreference
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/notifications", tags=["Notifications"])
# ---------------------------------------------------------------------------
# Request / Response models (inline — no Pydantic schema file to keep it simple)
# ---------------------------------------------------------------------------
class RegisterDeviceRequest(BaseModel):
platform: str # "android" | "ios" | "web"
push_token: str
class UpdatePreferencesRequest(BaseModel):
push_enabled: Optional[bool] = None
email_enabled: Optional[bool] = None
alert_notifications: Optional[bool] = None
maintenance_notifications: Optional[bool] = None
event_notifications: Optional[bool] = None
general_notifications: Optional[bool] = None
class NotificationResponse(BaseModel):
id: str
user_id: str
title: str
body: str
category: str
is_read: bool
data: Optional[str]
created_at: str
read_at: Optional[str]
class NotificationPreferenceResponse(BaseModel):
push_enabled: bool
email_enabled: bool
alert_notifications: bool
maintenance_notifications: bool
event_notifications: bool
general_notifications: bool
updated_at: str
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _user_id_from_payload(payload: dict) -> UUID:
"""Extract and validate the user UUID from a JWT payload dict."""
raw = payload.get("sub")
if raw is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload: missing 'sub'",
)
try:
return UUID(raw)
except (ValueError, AttributeError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid user id in token: '{raw}'",
)
def _notification_to_dict(n: Notification) -> dict:
return {
"id": str(n.id),
"user_id": str(n.user_id),
"title": n.title,
"body": n.body,
"category": n.category,
"is_read": n.is_read,
"data": n.data,
"created_at": n.created_at.isoformat() if n.created_at else None,
"read_at": n.read_at.isoformat() if n.read_at else None,
}
def _device_to_dict(d: NotificationDevice) -> dict:
return {
"id": str(d.id),
"platform": d.platform,
"is_active": d.is_active,
"created_at": d.created_at.isoformat() if d.created_at else None,
}
# ===========================================================================
# GET /notifications — paginated list for the authenticated user
# ===========================================================================
@router.get(
"",
summary="List user notifications (paginated)",
description="Return paginated notifications for the authenticated user, newest first.",
)
async def list_notifications(
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
is_read: Optional[bool] = Query(None, description="Filter by read status (true/false)"),
category: Optional[str] = Query(
None,
description="Filter by category (general, alert, maintenance, event)",
),
session: AsyncSession = Depends(get_session),
current_user: dict = Depends(get_current_user),
) -> dict:
uid = _user_id_from_payload(current_user)
base_stmt = select(Notification).where(Notification.user_id == uid)
total_stmt = select(func.count(Notification.id)).where(Notification.user_id == uid)
# Optional filters
if is_read is not None:
base_stmt = base_stmt.where(Notification.is_read == is_read)
total_stmt = total_stmt.where(Notification.is_read == is_read)
if category is not None:
base_stmt = base_stmt.where(Notification.category == category)
total_stmt = total_stmt.where(Notification.category == category)
# Count
total_result = await session.execute(total_stmt)
total: int = total_result.scalar_one()
# Fetch page
offset = (page - 1) * page_size
stmt = (
base_stmt
.order_by(Notification.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
notifications = result.scalars().all()
pages = max(1, -(-total // page_size)) # ceiling division
return {
"items": [_notification_to_dict(n) for n in notifications],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
# ===========================================================================
# PUT /notifications/{notification_id}/read — mark as read
# ===========================================================================
@router.put(
"/{notification_id}/read",
summary="Mark a notification as read",
description="Mark a single notification as read for the authenticated user.",
)
async def mark_notification_read(
notification_id: str,
session: AsyncSession = Depends(get_session),
current_user: dict = Depends(get_current_user),
) -> dict:
uid = _user_id_from_payload(current_user)
# Validate path param
try:
nid = UUID(notification_id)
except (ValueError, AttributeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"notification_id must be a valid UUID, got '{notification_id}'",
)
# Fetch notification belonging to the user
stmt = select(Notification).where(
Notification.id == nid,
Notification.user_id == uid,
)
result = await session.execute(stmt)
notification = result.scalar_one_or_none()
if notification is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Notification {notification_id} not found",
)
if not notification.is_read:
notification.is_read = True
notification.read_at = datetime.now(timezone.utc)
session.add(notification)
await session.commit()
await session.refresh(notification)
return _notification_to_dict(notification)
# ===========================================================================
# POST /notifications/register-device — register an FCM/APNs device token
# ===========================================================================
@router.post(
"/register-device",
summary="Register a push device token (FCM / APNs)",
description=(
"Register or update a device token for push notifications. "
"If the push_token already exists, its platform and user association are updated."
),
status_code=status.HTTP_201_CREATED,
)
async def register_device(
payload: RegisterDeviceRequest,
session: AsyncSession = Depends(get_session),
current_user: dict = Depends(get_current_user),
) -> dict:
uid = _user_id_from_payload(current_user)
platform = payload.platform.strip().lower()
if platform not in ("android", "ios", "web"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="platform must be one of: android, ios, web",
)
# Upsert logic: if the push_token already exists, update the user + platform
existing = await session.execute(
select(NotificationDevice).where(
NotificationDevice.push_token == payload.push_token
)
)
device = existing.scalar_one_or_none()
if device is not None:
# Re-associate and ensure active
device.user_id = uid
device.platform = platform
device.is_active = True
session.add(device)
await session.commit()
await session.refresh(device)
else:
device = NotificationDevice(
user_id=uid,
platform=platform,
push_token=payload.push_token,
is_active=True,
)
session.add(device)
await session.commit()
await session.refresh(device)
return _device_to_dict(device)
# ===========================================================================
# GET /notifications/preferences — fetch user preferences
# ===========================================================================
@router.get(
"/preferences",
summary="Get notification preferences",
description="Return the authenticated user's notification preferences.",
)
async def get_preferences(
session: AsyncSession = Depends(get_session),
current_user: dict = Depends(get_current_user),
) -> dict:
uid = _user_id_from_payload(current_user)
stmt = select(NotificationPreference).where(
NotificationPreference.user_id == uid
)
result = await session.execute(stmt)
pref = result.scalar_one_or_none()
if pref is None:
# Auto-create default preferences
pref = NotificationPreference(user_id=uid)
session.add(pref)
await session.commit()
await session.refresh(pref)
return {
"push_enabled": pref.push_enabled,
"email_enabled": pref.email_enabled,
"alert_notifications": pref.alert_notifications,
"maintenance_notifications": pref.maintenance_notifications,
"event_notifications": pref.event_notifications,
"general_notifications": pref.general_notifications,
"updated_at": pref.updated_at.isoformat() if pref.updated_at else None,
}
# ===========================================================================
# PUT /notifications/preferences — update user preferences
# ===========================================================================
@router.put(
"/preferences",
summary="Update notification preferences",
description="Partially update the authenticated user's notification preferences.",
)
async def update_preferences(
payload: UpdatePreferencesRequest,
session: AsyncSession = Depends(get_session),
current_user: dict = Depends(get_current_user),
) -> dict:
uid = _user_id_from_payload(current_user)
# Fetch or create
stmt = select(NotificationPreference).where(
NotificationPreference.user_id == uid
)
result = await session.execute(stmt)
pref = result.scalar_one_or_none()
if pref is None:
# Create defaults first, then patch
pref = NotificationPreference(user_id=uid)
session.add(pref)
await session.commit()
await session.refresh(pref)
# Apply only provided fields (PATCH-like semantics)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(pref, field, value)
pref.updated_at = datetime.now(timezone.utc)
session.add(pref)
await session.commit()
await session.refresh(pref)
return {
"push_enabled": pref.push_enabled,
"email_enabled": pref.email_enabled,
"alert_notifications": pref.alert_notifications,
"maintenance_notifications": pref.maintenance_notifications,
"event_notifications": pref.event_notifications,
"general_notifications": pref.general_notifications,
"updated_at": pref.updated_at.isoformat() if pref.updated_at else None,
}