feat: backend FastAPI Smart App City — auth JWT, IoT, GIS, notifications, reporting
This commit is contained in:
376
smart-app-city/backend/app/routes/notifications.py
Normal file
376
smart-app-city/backend/app/routes/notifications.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user