377 lines
12 KiB
Python
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,
|
|
}
|