feat: backend FastAPI Smart App City — auth JWT, IoT, GIS, notifications, reporting

This commit is contained in:
Eric FELIXINE
2026-06-01 14:47:05 -04:00
parent 31334b5ce5
commit 08ca495bde
24 changed files with 2904 additions and 0 deletions

View File

@@ -0,0 +1,319 @@
"""Authentication routes — register, login, refresh, profile, logout."""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
import redis.asyncio as redis
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import (
ACCESS_TOKEN_EXPIRE_MINUTES,
REFRESH_TOKEN_EXPIRE_DAYS,
create_access_token,
create_refresh_token,
verify_token,
)
from app.config import settings
from app.database import get_session
from app.models.models import User
from app.schemas.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
from passlib.context import CryptContext
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/auth", tags=["Authentication"])
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _hash_password(password: str) -> str:
return _pwd_context.hash(password)
def _verify_password(plain: str, hashed: str) -> bool:
return _pwd_context.verify(plain, hashed)
# ---------------------------------------------------------------------------
# Redis helpers (lazy singleton client)
# ---------------------------------------------------------------------------
_redis_client: Optional[redis.Redis] = None
def _get_redis() -> redis.Redis:
"""Return a singleton Redis client built from *settings.REDIS_URL*."""
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
async def _blacklist_token(token: str, ttl: int) -> None:
"""Store *token* in the blacklist with *ttl* seconds expiry."""
r = _get_redis()
await r.setex(f"token:blacklist:{token}", ttl, "1")
async def _is_token_blacklisted(token: str) -> bool:
"""Return True if *token* has been blacklisted."""
r = _get_redis()
return await r.exists(f"token:blacklist:{token}") > 0
async def _enforce_not_blacklisted(token: str) -> None:
if await _is_token_blacklisted(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been revoked",
headers={"WWW-Authenticate": "Bearer"},
)
# ---------------------------------------------------------------------------
# OAuth2 scheme
# ---------------------------------------------------------------------------
from fastapi.security import OAuth2PasswordBearer
_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# ---------------------------------------------------------------------------
# Dependency: load current user from JWT (with blacklist check)
# ---------------------------------------------------------------------------
async def get_current_user_dep(
token: str = Depends(_oauth2_scheme),
session: AsyncSession = Depends(get_session),
) -> User:
"""Full dependency: verify token string, check blacklist, load user."""
await _enforce_not_blacklisted(token)
payload = verify_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
stmt = select(User).where(User.id == UUID(user_id))
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
# ═══════════════════════════════════════════════════════════════════════════
# Request / Response models
# ═══════════════════════════════════════════════════════════════════════════
class RefreshRequest(BaseModel):
refresh_token: str
class UserUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
avatar_url: Optional[str] = None
# ═══════════════════════════════════════════════════════════════════════════
# POST /auth/register
# ═══════════════════════════════════════════════════════════════════════════
@router.post(
"/register",
response_model=TokenResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(
payload: UserCreate,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
"""Create a new user, hash the password, return access + refresh tokens."""
# — uniqueness checks —
for field, value, label in [
("email", payload.email, "Email"),
("username", payload.username, "Username"),
]:
stmt = select(User).where(getattr(User, field) == value)
result = await session.execute(stmt)
if result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{label} already registered",
)
# — create user —
user = User(
email=payload.email,
username=payload.username,
hashed_password=_hash_password(payload.password),
first_name=payload.first_name,
last_name=payload.last_name,
phone=payload.phone,
role="user",
is_active=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(user)
await session.commit()
await session.refresh(user)
# — issue tokens —
token_data = {"sub": str(user.id), "role": user.role}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
)
# ═══════════════════════════════════════════════════════════════════════════
# POST /auth/login
# ═══════════════════════════════════════════════════════════════════════════
@router.post("/login", response_model=TokenResponse)
async def login(
payload: UserLogin,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
"""Verify credentials and return access + refresh tokens."""
stmt = select(User).where(User.email == payload.email)
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user is None or not _verify_password(payload.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated",
)
token_data = {"sub": str(user.id), "role": user.role}
access_token = create_access_token(token_data)
refresh_token = create_refresh_token(token_data)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
)
# ═══════════════════════════════════════════════════════════════════════════
# POST /auth/refresh
# ═══════════════════════════════════════════════════════════════════════════
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
payload: RefreshRequest,
session: AsyncSession = Depends(get_session),
) -> TokenResponse:
"""Rotate a refresh token → new access token (+ new refresh token)."""
data = verify_token(payload.refresh_token)
if data.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token is not a refresh token",
)
# Blacklist check
await _enforce_not_blacklisted(payload.refresh_token)
user_id = data.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
# Load user
stmt = select(User).where(User.id == UUID(user_id))
result = await session.execute(stmt)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
# Rotate: issue new pair, blacklist old refresh token
token_data = {"sub": str(user.id), "role": user.role}
new_access = create_access_token(token_data)
new_refresh = create_refresh_token(token_data)
# Blacklist the used refresh token until it naturally expires
await _blacklist_token(payload.refresh_token, REFRESH_TOKEN_EXPIRE_DAYS * 86400)
return TokenResponse(
access_token=new_access,
refresh_token=new_refresh,
token_type="bearer",
)
# ═══════════════════════════════════════════════════════════════════════════
# GET /auth/me
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/me", response_model=UserResponse)
async def read_current_user(
current_user: User = Depends(get_current_user_dep),
) -> UserResponse:
"""Return the authenticated user's profile."""
return UserResponse.model_validate(current_user)
# ═══════════════════════════════════════════════════════════════════════════
# PUT /auth/me
# ═══════════════════════════════════════════════════════════════════════════
@router.put("/me", response_model=UserResponse)
async def update_current_user(
payload: UserUpdate,
current_user: User = Depends(get_current_user_dep),
session: AsyncSession = Depends(get_session),
) -> UserResponse:
"""Update the authenticated user's profile fields."""
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
current_user.updated_at = datetime.now(timezone.utc)
session.add(current_user)
await session.commit()
await session.refresh(current_user)
return UserResponse.model_validate(current_user)
# ═══════════════════════════════════════════════════════════════════════════
# POST /auth/logout
# ═══════════════════════════════════════════════════════════════════════════
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
current_user: User = Depends(get_current_user_dep),
token: str = Depends(_oauth2_scheme),
) -> None:
"""Blacklist the current access token (Redis) so it cannot be reused."""
await _blacklist_token(token, ACCESS_TOKEN_EXPIRE_MINUTES * 60)

View File

@@ -0,0 +1,443 @@
"""GIS routes — layers, features, geocoding search, and reverse geocoding.
All endpoints require JWT authentication (Bearer token).
"""
from __future__ import annotations
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.database import get_session
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/gis", tags=["GIS"])
# ---------------------------------------------------------------------------
# GIS Layer model (in-memory store for now, can be replaced by SQLModel)
# ---------------------------------------------------------------------------
# Sample GIS layers for Martinique digital twin
SAMPLE_LAYERS: list[dict[str, Any]] = [
{
"id": "layer-zones",
"name": "Zones",
"description": "Administrative and operational zones of Martinique",
"type": "vector",
"feature_count": 0,
},
{
"id": "layer-sensors",
"name": "Capteurs IoT",
"description": "All IoT sensor locations",
"type": "vector",
"feature_count": 0,
},
{
"id": "layer-road-network",
"name": "Réseau routier",
"description": "Martinique road network",
"type": "vector",
"feature_count": 0,
},
{
"id": "layer-buildings",
"name": "Bâtiments",
"description": "Building footprints",
"type": "vector",
"feature_count": 0,
},
{
"id": "layer-environment",
"name": "Environnement",
"description": "Environmental data layers (air quality, temperature, humidity)",
"type": "raster",
"feature_count": 0,
},
]
# Sample GeoJSON features per layer
SAMPLE_FEATURES: dict[str, dict[str, Any]] = {
"layer-zones": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "zone-fort-france",
"properties": {
"name": "Fort-de-France",
"description": "Capital city zone",
"color": "#E53935",
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-61.083, 14.595],
[-61.055, 14.595],
[-61.055, 14.635],
[-61.083, 14.635],
[-61.083, 14.595],
]
],
},
},
{
"type": "Feature",
"id": "zone-le-lamentin",
"properties": {
"name": "Le Lamentin",
"description": "Industrial and commercial zone",
"color": "#1E88E5",
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-61.015, 14.598],
[-60.985, 14.598],
[-60.985, 14.625],
[-61.015, 14.625],
[-61.015, 14.598],
]
],
},
},
],
},
"layer-sensors": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "sensor-temp-01",
"properties": {
"name": "Température Centre-Ville",
"type": "temperature",
"status": "active",
"last_value": 28.5,
"unit": "°C",
},
"geometry": {
"type": "Point",
"coordinates": [-61.07, 14.61],
},
},
{
"type": "Feature",
"id": "sensor-aq-01",
"properties": {
"name": "Qualité de l'air Schoelcher",
"type": "air_quality",
"status": "active",
"last_value": 42.0,
"unit": "AQI",
},
"geometry": {
"type": "Point",
"coordinates": [-61.095, 14.615],
},
},
],
},
"layer-road-network": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "road-n1",
"properties": {
"name": "Route Nationale 1",
"type": "primary",
"surface": "asphalt",
},
"geometry": {
"type": "LineString",
"coordinates": [
[-61.055, 14.61],
[-61.02, 14.605],
[-60.99, 14.61],
],
},
},
],
},
"layer-buildings": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "building-mairie",
"properties": {
"name": "Hôtel de Ville",
"type": "public",
"address": "1 Rue de l'Hôtel de Ville, Fort-de-France",
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-61.0715, 14.6055],
[-61.0705, 14.6055],
[-61.0705, 14.6065],
[-61.0715, 14.6065],
[-61.0715, 14.6055],
]
],
},
},
],
},
"layer-environment": {
"type": "FeatureCollection",
"features": [],
},
}
# Sample geocoding results for Martinique addresses
SAMPLE_ADDRESSES: list[dict[str, Any]] = [
{
"id": "addr-1",
"label": "Fort-de-France, Martinique",
"street": "",
"city": "Fort-de-France",
"postal_code": "97200",
"region": "Martinique",
"country": "France",
"latitude": 14.6161,
"longitude": -61.0588,
},
{
"id": "addr-2",
"label": "Le Lamentin, Martinique",
"street": "",
"city": "Le Lamentin",
"postal_code": "97232",
"region": "Martinique",
"country": "France",
"latitude": 14.6167,
"longitude": -61.0000,
},
{
"id": "addr-3",
"label": "Schoelcher, Martinique",
"street": "",
"city": "Schoelcher",
"postal_code": "97233",
"region": "Martinique",
"country": "France",
"latitude": 14.6167,
"longitude": -61.0833,
},
{
"id": "addr-4",
"label": "Le Robert, Martinique",
"street": "",
"city": "Le Robert",
"postal_code": "97222",
"region": "Martinique",
"country": "France",
"latitude": 14.6833,
"longitude": -60.9500,
},
{
"id": "addr-5",
"label": "Ducos, Martinique",
"street": "",
"city": "Ducos",
"postal_code": "97224",
"region": "Martinique",
"country": "France",
"latitude": 14.5833,
"longitude": -60.9833,
},
{
"id": "addr-6",
"label": "1 Rue de l'Hôtel de Ville, Fort-de-France",
"street": "1 Rue de l'Hôtel de Ville",
"city": "Fort-de-France",
"postal_code": "97200",
"region": "Martinique",
"country": "France",
"latitude": 14.6060,
"longitude": -61.0710,
},
{
"id": "addr-7",
"label": "Pointe du Bout, Trois-Îlets, Martinique",
"street": "Pointe du Bout",
"city": "Trois-Îlets",
"postal_code": "97229",
"region": "Martinique",
"country": "France",
"latitude": 14.5333,
"longitude": -61.0333,
},
{
"id": "addr-8",
"label": "Saint-Pierre, Martinique",
"street": "",
"city": "Saint-Pierre",
"postal_code": "97250",
"region": "Martinique",
"country": "France",
"latitude": 14.7500,
"longitude": -61.1833,
},
]
# ===========================================================================
# HELPERS
# ===========================================================================
def _find_layer(layer_id: str) -> Optional[dict[str, Any]]:
"""Return the layer dict matching *layer_id*, or None."""
for layer in SAMPLE_LAYERS:
if layer["id"] == layer_id:
return layer
return None
# ===========================================================================
# ENDPOINTS
# ===========================================================================
@router.get(
"/layers",
summary="List all available GIS layers",
response_model=dict,
)
async def list_layers(
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return the list of all available GIS layers."""
return {
"layers": SAMPLE_LAYERS,
"total": len(SAMPLE_LAYERS),
}
@router.get(
"/layers/{layer_id}/features",
summary="Get GeoJSON features for a specific layer",
response_model=dict,
)
async def get_layer_features(
layer_id: str,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Return the GeoJSON FeatureCollection for the given *layer_id*.
Raises 404 if the layer does not exist.
"""
layer = _find_layer(layer_id)
if layer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Layer '{layer_id}' not found",
)
features = SAMPLE_FEATURES.get(layer_id, {"type": "FeatureCollection", "features": []})
return {
"layer": layer,
"geojson": features,
}
@router.get(
"/search",
summary="Search for an address (forward geocoding)",
response_model=dict,
)
async def search_address(
q: str = Query(..., min_length=2, max_length=200, description="Search query (address, city, place name)"),
limit: int = Query(10, ge=1, le=50, description="Maximum number of results"),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Search for an address or place name in Martinique.
Returns a list of matching addresses with their coordinates.
The search is case-insensitive and matches against city, street,
postal code, and region fields.
"""
query_lower = q.lower().strip()
results: list[dict[str, Any]] = []
for addr in SAMPLE_ADDRESSES:
searchable = " ".join(
filter(
None,
[
addr.get("street", ""),
addr.get("city", ""),
addr.get("postal_code", ""),
addr.get("region", ""),
addr.get("label", ""),
],
)
).lower()
if query_lower in searchable:
results.append(addr)
if len(results) >= limit:
break
return {
"query": q,
"results": results,
"total": len(results),
}
@router.get(
"/reverse",
summary="Reverse geocoding — find address from coordinates",
response_model=dict,
)
async def reverse_geocode(
lat: float = Query(..., ge=-90.0, le=90.0, description="Latitude"),
lng: float = Query(..., ge=-180.0, le=180.0, description="Longitude"),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Find the nearest known address for the given coordinates.
Uses a simplified Euclidean distance calculation over the
Martinique sample addresses. In production this would call an
external geocoding service (Nominatim, Photon, etc.).
"""
# Find nearest sample address by approximate distance
best: Optional[dict[str, Any]] = None
best_dist = float("inf")
deg_to_km = 111.0 # approximate km per degree at equator
for addr in SAMPLE_ADDRESSES:
dlat = (addr["latitude"] - lat) * deg_to_km
dlng = (addr["longitude"] - lng) * deg_to_km
dist = (dlat**2 + dlng**2) ** 0.5
if dist < best_dist:
best_dist = dist
best = addr
if best is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No known address near the given coordinates",
)
return {
"query": {"latitude": lat, "longitude": lng},
"result": best,
"distance_km": round(best_dist, 3),
}

View File

@@ -0,0 +1,511 @@
"""IoT routes — sensors, zones, alerts, and dashboard statistics.
All endpoints require JWT authentication (Bearer token).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.jwt import get_current_user
from app.database import get_session
from app.models.models import Alert, Sensor, SensorReading, Zone
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/iot", tags=["IoT"])
# ---------------------------------------------------------------------------
# Helper: UUID path validation
# ---------------------------------------------------------------------------
def _validate_uuid(param: str, value: str) -> UUID:
"""Raise 400 if *value* is not a valid UUID."""
try:
return UUID(value)
except (ValueError, AttributeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{param} must be a valid UUID, got '{value}'",
)
# ===========================================================================
# SENSORS
# ===========================================================================
@router.get(
"/sensors",
summary="List all sensors (paginated)",
description="Return a paginated list of sensors with optional status filter.",
)
async def list_sensors(
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
status: Optional[str] = Query(None, description="Filter by sensor status (active, inactive, maintenance)"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
base_stmt = select(Sensor)
total_stmt = select(func.count(Sensor.id))
if status is not None:
base_stmt = base_stmt.where(Sensor.status == status)
total_stmt = total_stmt.where(Sensor.status == status)
# Count total
total_result = await session.execute(total_stmt)
total: int = total_result.scalar_one()
# Paginated fetch — include zone relationship
offset = (page - 1) * page_size
stmt = (
base_stmt
.options(selectinload(Sensor.zone))
.order_by(Sensor.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
sensors = result.scalars().all()
pages = max(1, -(-total // page_size)) # ceil division
return {
"items": [_sensor_to_dict(s) for s in sensors],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
@router.get(
"/sensors/{sensor_id}",
summary="Get sensor detail",
description="Return full details for a single sensor, including its zone.",
)
async def get_sensor(
sensor_id: str,
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
sid = _validate_uuid("sensor_id", sensor_id)
stmt = (
select(Sensor)
.options(selectinload(Sensor.zone))
.where(Sensor.id == sid)
)
result = await session.execute(stmt)
sensor = result.scalar_one_or_none()
if sensor is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sensor {sensor_id} not found",
)
return _sensor_to_dict(sensor)
@router.get(
"/sensors/{sensor_id}/data",
summary="Historical sensor readings",
description="Return readings for a sensor within an optional time range.",
)
async def get_sensor_data(
sensor_id: str,
from_: Optional[datetime] = Query(
None,
alias="from",
description="Start of time range (ISO 8601). Defaults to 24 h ago.",
),
to: Optional[datetime] = Query(
None,
description="End of time range (ISO 8601). Defaults to now.",
),
limit: int = Query(100, ge=1, le=10_000, description="Max number of readings"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
sid = _validate_uuid("sensor_id", sensor_id)
# Ensure sensor exists
sensor_result = await session.execute(select(Sensor).where(Sensor.id == sid))
if sensor_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Sensor {sensor_id} not found",
)
now = datetime.now(timezone.utc)
from_dt = from_ or (now - timedelta(hours=24))
to_dt = to or now
stmt = (
select(SensorReading)
.where(
SensorReading.sensor_id == sid,
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
.order_by(SensorReading.recorded_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
readings = result.scalars().all()
return {
"sensor_id": str(sid),
"from": from_dt.isoformat(),
"to": to_dt.isoformat(),
"limit": limit,
"total": len(readings),
"readings": [_reading_to_dict(r) for r in readings],
}
# ===========================================================================
# ZONES
# ===========================================================================
@router.get(
"/zones",
summary="List all zones",
description="Return all zones ordered by creation date (newest first).",
)
async def list_zones(
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
stmt = select(Zone).order_by(Zone.created_at.desc())
result = await session.execute(stmt)
zones = result.scalars().all()
return {"items": [_zone_to_dict(z) for z in zones], "total": len(zones)}
@router.get(
"/zones/{zone_id}/sensors",
summary="Sensors in a zone",
description="Return all sensors belonging to a specific zone.",
)
async def get_zone_sensors(
zone_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
zid = _validate_uuid("zone_id", zone_id)
# Ensure zone exists
zone_result = await session.execute(select(Zone).where(Zone.id == zid))
if zone_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Zone {zone_id} not found",
)
total_result = await session.execute(
select(func.count(Sensor.id)).where(Sensor.zone_id == zid)
)
total: int = total_result.scalar_one()
offset = (page - 1) * page_size
stmt = (
select(Sensor)
.where(Sensor.zone_id == zid)
.order_by(Sensor.name)
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
sensors = result.scalars().all()
pages = max(1, -(-total // page_size))
return {
"zone_id": str(zid),
"items": [_sensor_to_dict(s) for s in sensors],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
# ===========================================================================
# ALERTS
# ===========================================================================
@router.get(
"/alerts",
summary="List alerts (paginated)",
description="Return alerts with optional status and severity filters.",
)
async def list_alerts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None, description="Filter by status (active, resolved, acknowledged)"),
severity: Optional[str] = Query(None, description="Filter by severity (low, medium, high, critical)"),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
base_stmt = select(Alert)
total_stmt = select(func.count(Alert.id))
if status is not None:
base_stmt = base_stmt.where(Alert.status == status)
total_stmt = total_stmt.where(Alert.status == status)
if severity is not None:
base_stmt = base_stmt.where(Alert.severity == severity)
total_stmt = total_stmt.where(Alert.severity == severity)
total_result = await session.execute(total_stmt)
total: int = total_result.scalar_one()
offset = (page - 1) * page_size
stmt = (
base_stmt
.options(selectinload(Alert.sensor))
.order_by(Alert.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await session.execute(stmt)
alerts = result.scalars().all()
pages = max(1, -(-total // page_size))
return {
"items": [_alert_to_dict(a) for a in alerts],
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
# ===========================================================================
# DASHBOARD STATS
# ===========================================================================
@router.get(
"/stats",
summary="Dashboard statistics (last 24 h)",
description="Return aggregated IoT statistics for the last 24 hours.",
)
async def get_stats(
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
now = datetime.now(timezone.utc)
since = now - timedelta(hours=24)
# ── Sensor counts ─────────────────────────────────────────────────
total_sensors = await _scalar_count(session, func.count(Sensor.id))
active_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "active"
)
# ── Readings last 24 h ────────────────────────────────────────────
readings_24h = await _scalar_count(
session,
func.count(SensorReading.id),
SensorReading.recorded_at >= since,
)
# Average reading value over 24 h
avg_val_result = await session.execute(
select(func.avg(SensorReading.value)).where(
SensorReading.recorded_at >= since
)
)
avg_value: Optional[float] = avg_val_result.scalar_one()
# ── Alerts last 24 h ──────────────────────────────────────────────
total_alerts_24h = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= since,
)
active_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.status == "active",
)
critical_alerts = await _scalar_count(
session,
func.count(Alert.id),
(Alert.status == "active") & (Alert.severity == "critical"),
)
# ── Zones ─────────────────────────────────────────────────────────
total_zones = await _scalar_count(session, func.count(Zone.id))
# ── Alerts by severity (last 24 h) ────────────────────────────────
severity_rows = await session.execute(
select(Alert.severity, func.count(Alert.id))
.where(Alert.created_at >= since)
.group_by(Alert.severity)
)
alerts_by_severity = {row[0]: row[1] for row in severity_rows.all()}
# ── Alerts by status ──────────────────────────────────────────────
status_rows = await session.execute(
select(Alert.status, func.count(Alert.id))
.where(Alert.created_at >= since)
.group_by(Alert.status)
)
alerts_by_status = {row[0]: row[1] for row in status_rows.all()}
# ── Sensors by type ───────────────────────────────────────────────
type_rows = await session.execute(
select(Sensor.type, func.count(Sensor.id)).group_by(Sensor.type)
)
sensors_by_type = {row[0]: row[1] for row in type_rows.all()}
# ── Sensors by status ─────────────────────────────────────────────
sensor_status_rows = await session.execute(
select(Sensor.status, func.count(Sensor.id)).group_by(Sensor.status)
)
sensors_by_status = {row[0]: row[1] for row in sensor_status_rows.all()}
# ── Avg battery level ─────────────────────────────────────────────
battery_result = await session.execute(
select(func.avg(Sensor.battery_level)).where(
Sensor.battery_level.is_not(None)
)
)
avg_battery: Optional[float] = battery_result.scalar_one()
return {
"period": "24h",
"generated_at": now.isoformat(),
"sensors": {
"total": total_sensors,
"active": active_sensors,
"by_type": sensors_by_type,
"by_status": sensors_by_status,
"avg_battery_level": round(avg_battery, 1) if avg_battery is not None else None,
},
"readings": {
"last_24h_count": readings_24h,
"avg_value": round(avg_value, 4) if avg_value is not None else None,
},
"zones": {
"total": total_zones,
},
"alerts": {
"last_24h_count": total_alerts_24h,
"active": active_alerts,
"critical": critical_alerts,
"by_severity": alerts_by_severity,
"by_status": alerts_by_status,
},
}
# ===========================================================================
# Internal helpers
# ===========================================================================
async def _scalar_count(
session: AsyncSession,
expression,
*filters,
) -> int:
"""Shorthand: execute a COUNT query and return the integer scalar."""
stmt = select(expression)
if filters:
for f in filters:
stmt = stmt.where(f)
result = await session.execute(stmt)
return result.scalar_one()
def _sensor_to_dict(s: Sensor) -> dict:
"""Serialize a Sensor model to a flat dictionary."""
d = {
"id": str(s.id),
"name": s.name,
"type": s.type,
"status": s.status,
"latitude": s.latitude,
"longitude": s.longitude,
"zone_id": str(s.zone_id) if s.zone_id else None,
"last_value": s.last_value,
"last_reading_at": s.last_reading_at.isoformat() if s.last_reading_at else None,
"battery_level": s.battery_level,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
if s.zone is not None:
d["zone"] = {
"id": str(s.zone.id),
"name": s.zone.name,
}
return d
def _reading_to_dict(r: SensorReading) -> dict:
"""Serialize a SensorReading model to a dictionary."""
return {
"id": str(r.id),
"sensor_id": str(r.sensor_id),
"value": r.value,
"unit": r.unit,
"quality": r.quality,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _zone_to_dict(z: Zone) -> dict:
"""Serialize a Zone model to a dictionary."""
return {
"id": str(z.id),
"name": z.name,
"description": z.description,
"color": z.color,
"geojson": z.geojson,
"created_at": z.created_at.isoformat() if z.created_at else None,
}
def _alert_to_dict(a: Alert) -> dict:
"""Serialize an Alert model to a dictionary."""
d = {
"id": str(a.id),
"sensor_id": str(a.sensor_id),
"type": a.type,
"severity": a.severity,
"message": a.message,
"value": a.value,
"threshold": a.threshold,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
"resolved_at": a.resolved_at.isoformat() if a.resolved_at else None,
}
if a.sensor is not None:
d["sensor"] = {
"id": str(a.sensor.id),
"name": a.sensor.name,
"type": a.sensor.type,
}
return d

View 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,
}

View File

@@ -0,0 +1,570 @@
"""Reporting routes — daily, weekly, custom reports and PDF export.
All endpoints require JWT authentication (Bearer token).
"""
from __future__ import annotations
import io
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
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 Alert, Sensor, SensorReading, Zone
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/reports", tags=["Reporting"])
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _scalar_count(
session: AsyncSession,
expression,
*filters,
) -> int:
"""Shorthand: execute a COUNT query and return the integer scalar."""
stmt = select(expression)
if filters:
for f in filters:
stmt = stmt.where(f)
result = await session.execute(stmt)
return result.scalar_one()
async def _build_report_data(
session: AsyncSession,
from_dt: datetime,
to_dt: datetime,
period_label: str,
) -> dict:
"""Aggregate IoT data for the given time window and return a report dict."""
# ── Sensor overview ──────────────────────────────────────────────
total_sensors = await _scalar_count(session, func.count(Sensor.id))
active_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "active"
)
inactive_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "inactive"
)
maintenance_sensors = await _scalar_count(
session, func.count(Sensor.id), Sensor.status == "maintenance"
)
# ── Readings in period ───────────────────────────────────────────
readings_count = await _scalar_count(
session,
func.count(SensorReading.id),
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
avg_value_result = await session.execute(
select(func.avg(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
avg_value: Optional[float] = avg_value_result.scalar_one()
min_value_result = await session.execute(
select(func.min(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
min_value: Optional[float] = min_value_result.scalar_one()
max_value_result = await session.execute(
select(func.max(SensorReading.value)).where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
)
max_value: Optional[float] = max_value_result.scalar_one()
# ── Readings by sensor type ──────────────────────────────────────
type_avg_rows = await session.execute(
select(Sensor.type, func.avg(SensorReading.value), func.count(SensorReading.id))
.join(SensorReading, SensorReading.sensor_id == Sensor.id)
.where(
SensorReading.recorded_at >= from_dt,
SensorReading.recorded_at <= to_dt,
)
.group_by(Sensor.type)
)
readings_by_type = {
row[0]: {"avg_value": round(row[1], 4) if row[1] is not None else None, "count": row[2]}
for row in type_avg_rows.all()
}
# ── Alerts in period ─────────────────────────────────────────────
total_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
active_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.status == "active",
)
resolved_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.status == "resolved",
)
critical_alerts = await _scalar_count(
session,
func.count(Alert.id),
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
Alert.severity == "critical",
)
# ── Alerts by severity ───────────────────────────────────────────
severity_rows = await session.execute(
select(Alert.severity, func.count(Alert.id))
.where(
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
.group_by(Alert.severity)
)
alerts_by_severity = {row[0]: row[1] for row in severity_rows.all()}
# ── Alerts by type ───────────────────────────────────────────────
type_rows = await session.execute(
select(Alert.type, func.count(Alert.id))
.where(
Alert.created_at >= from_dt,
Alert.created_at <= to_dt,
)
.group_by(Alert.type)
)
alerts_by_type = {row[0]: row[1] for row in type_rows.all()}
# ── Zones ────────────────────────────────────────────────────────
total_zones = await _scalar_count(session, func.count(Zone.id))
# ── Avg battery level ────────────────────────────────────────────
battery_result = await session.execute(
select(func.avg(Sensor.battery_level)).where(
Sensor.battery_level.is_not(None)
)
)
avg_battery: Optional[float] = battery_result.scalar_one()
# ── Low-battery sensors (< 20 %) ────────────────────────────────
low_battery_count = await _scalar_count(
session,
func.count(Sensor.id),
Sensor.battery_level.is_not(None),
Sensor.battery_level < 20,
)
now = datetime.now(timezone.utc)
return {
"period": period_label,
"from": from_dt.isoformat(),
"to": to_dt.isoformat(),
"generated_at": now.isoformat(),
"sensors": {
"total": total_sensors,
"active": active_sensors,
"inactive": inactive_sensors,
"maintenance": maintenance_sensors,
"avg_battery_level": round(avg_battery, 1) if avg_battery is not None else None,
"low_battery_count": low_battery_count,
},
"readings": {
"count": readings_count,
"avg_value": round(avg_value, 4) if avg_value is not None else None,
"min_value": round(min_value, 4) if min_value is not None else None,
"max_value": round(max_value, 4) if max_value is not None else None,
"by_type": readings_by_type,
},
"zones": {
"total": total_zones,
},
"alerts": {
"total": total_alerts,
"active": active_alerts,
"resolved": resolved_alerts,
"critical": critical_alerts,
"by_severity": alerts_by_severity,
"by_type": alerts_by_type,
},
}
def _generate_pdf_bytes(report: dict) -> bytes:
"""Generate a minimal PDF (text-based) from the report data.
Uses only stdlib — no external PDF dependency required.
Produces a valid PDF 1.4 document with the report content as text.
"""
lines: list[str] = []
lines.append("=" * 60)
lines.append(" SMART CITY DIGITAL TWIN — MARTINIQUE")
lines.append(f" RAPPORT {report['period'].upper()}")
lines.append("=" * 60)
lines.append("")
lines.append(f" Periode : {report['from']} -> {report['to']}")
lines.append(f" Genere le : {report['generated_at']}")
lines.append("")
lines.append("-" * 60)
lines.append(" CAPTEURS")
lines.append("-" * 60)
s = report["sensors"]
lines.append(f" Total : {s['total']}")
lines.append(f" Actifs : {s['active']}")
lines.append(f" Inactifs : {s['inactive']}")
lines.append(f" Maintenance : {s['maintenance']}")
lines.append(f" Batterie moy. : {s['avg_battery_level']}%")
lines.append(f" Batterie basse : {s['low_battery_count']} capteurs")
lines.append("")
lines.append("-" * 60)
lines.append(" LECTURES")
lines.append("-" * 60)
r = report["readings"]
lines.append(f" Nombre : {r['count']}")
lines.append(f" Valeur moy. : {r['avg_value']}")
lines.append(f" Valeur min. : {r['min_value']}")
lines.append(f" Valeur max. : {r['max_value']}")
if r["by_type"]:
lines.append("")
lines.append(" Par type de capteur:")
for sensor_type, data in r["by_type"].items():
lines.append(f" - {sensor_type}: avg={data['avg_value']}, n={data['count']}")
lines.append("")
lines.append("-" * 60)
lines.append(" ZONES")
lines.append("-" * 60)
lines.append(f" Total : {report['zones']['total']}")
lines.append("")
lines.append("-" * 60)
lines.append(" ALERTES")
lines.append("-" * 60)
a = report["alerts"]
lines.append(f" Total : {a['total']}")
lines.append(f" Actives : {a['active']}")
lines.append(f" Resolues : {a['resolved']}")
lines.append(f" Critiques : {a['critical']}")
if a["by_severity"]:
lines.append("")
lines.append(" Par severite:")
for sev, cnt in a["by_severity"].items():
lines.append(f" - {sev}: {cnt}")
if a["by_type"]:
lines.append("")
lines.append(" Par type:")
for atype, cnt in a["by_type"].items():
lines.append(f" - {atype}: {cnt}")
lines.append("")
lines.append("=" * 60)
lines.append(" FIN DU RAPPORT")
lines.append("=" * 60)
text = "\n".join(lines) + "\n"
# Build a minimal valid PDF 1.4 document
buf = io.BytesIO()
offsets: list[int] = []
def write(s: str) -> None:
buf.write(s.encode("latin-1"))
# Object 1: Catalog
offsets.append(buf.tell())
write("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\n")
# Object 2: Pages
offsets.append(buf.tell())
write("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\n")
# Object 3: Page
offsets.append(buf.tell())
write(
"3 0 obj\n"
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\n"
"endobj\n\n"
)
# Object 4: Content stream
escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
stream_lines = [
"BT",
"/F1 10 Tf",
"50 750 Td",
f"({escaped}) Tj",
"ET",
]
stream = "\n".join(stream_lines) + "\n"
offsets.append(buf.tell())
write(
f"4 0 obj\n<< /Length {len(stream)} >>\nstream\n"
f"{stream}"
"endstream\nendobj\n\n"
)
# Object 5: Font
offsets.append(buf.tell())
write(
"5 0 obj\n"
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\n"
"endobj\n\n"
)
# Cross-reference table
xref_offset = buf.tell()
write("xref\n")
write(f"0 {len(offsets) + 1}\n")
write("0000000000 65535 f \n")
for off in offsets:
write(f"{off:010d} 00000 n \n")
# Trailer
write(
"trailer\n"
f"<< /Size {len(offsets) + 1} /Root 1 0 R >>\n"
"startxref\n"
f"{xref_offset}\n"
"%%EOF\n"
)
return buf.getvalue()
# ===========================================================================
# GET /reports/daily
# ===========================================================================
@router.get(
"/daily",
summary="Daily report",
description="Return an aggregated IoT report for a specific date (defaults to today).",
)
async def daily_report(
date: Optional[str] = Query(
None,
description="Date in YYYY-MM-DD format. Defaults to today (UTC).",
pattern=r"^\d{4}-\d{2}-\d{2}$",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
if date is not None:
try:
day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
day = datetime.now(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
from_dt = day
to_dt = day + timedelta(days=1) - timedelta(microseconds=1)
return await _build_report_data(session, from_dt, to_dt, period_label="daily")
# ===========================================================================
# GET /reports/weekly
# ===========================================================================
@router.get(
"/weekly",
summary="Weekly report",
description="Return an aggregated IoT report for a specific week. "
"Defaults to the current ISO week (MondaySunday).",
)
async def weekly_report(
week: Optional[int] = Query(
None,
ge=1,
le=53,
description="ISO week number (1-53). Defaults to current week.",
),
year: Optional[int] = Query(
None,
ge=2020,
le=2100,
description="Year. Defaults to current year.",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
now = datetime.now(timezone.utc)
if week is not None and year is not None:
# Monday of the given ISO week
from_dt = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").replace(
tzinfo=timezone.utc
)
elif week is not None and year is None:
from_dt = datetime.strptime(
f"{now.isocalendar()[0]}-W{week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
else:
# Current ISO week
iso_year, iso_week, _ = now.isocalendar()
from_dt = datetime.strptime(
f"{iso_year}-W{iso_week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
to_dt = from_dt + timedelta(weeks=1) - timedelta(microseconds=1)
return await _build_report_data(session, from_dt, to_dt, period_label="weekly")
# ===========================================================================
# GET /reports/custom
# ===========================================================================
@router.get(
"/custom",
summary="Custom date-range report",
description="Return an aggregated IoT report for a custom time window.",
)
async def custom_report(
from_: datetime = Query(
...,
alias="from",
description="Start of time range (ISO 8601). Required.",
),
to: datetime = Query(
...,
description="End of time range (ISO 8601). Required.",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> dict:
# Ensure timezone-aware
if from_.tzinfo is None:
from_ = from_.replace(tzinfo=timezone.utc)
if to.tzinfo is None:
to = to.replace(tzinfo=timezone.utc)
if to <= from_:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="'to' must be after 'from'.",
)
max_range = timedelta(days=366)
if (to - from_) > max_range:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Date range cannot exceed 366 days.",
)
return await _build_report_data(
session, from_, to, period_label="custom"
)
# ===========================================================================
# GET /reports/export/pdf
# ===========================================================================
@router.get(
"/export/pdf",
summary="Export report as PDF",
description="Generate and download a PDF report for a given type and date.",
)
async def export_pdf(
type: str = Query(
...,
description="Report type: daily or weekly.",
pattern=r"^(daily|weekly)$",
),
date: Optional[str] = Query(
None,
description="Date in YYYY-MM-DD. For daily: that day. For weekly: any day in the week. Defaults to today.",
pattern=r"^\d{4}-\d{2}-\d{2}$",
),
session: AsyncSession = Depends(get_session),
_current_user: dict = Depends(get_current_user),
) -> StreamingResponse:
now = datetime.now(timezone.utc)
if type == "daily":
if date is not None:
try:
day = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
day = now.replace(hour=0, minute=0, second=0, microsecond=0)
from_dt = day
to_dt = day + timedelta(days=1) - timedelta(microseconds=1)
period_label = "daily"
filename_date = day.strftime("%Y-%m-%d")
else: # weekly
if date is not None:
try:
ref_day = datetime.strptime(date, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date format: '{date}'. Expected YYYY-MM-DD.",
)
else:
ref_day = now
iso_year, iso_week, _ = ref_day.isocalendar()
from_dt = datetime.strptime(
f"{iso_year}-W{iso_week:02d}-1", "%G-W%V-%u"
).replace(tzinfo=timezone.utc)
to_dt = from_dt + timedelta(weeks=1) - timedelta(microseconds=1)
period_label = "weekly"
filename_date = f"W{iso_week:02d}-{iso_year}"
report = await _build_report_data(session, from_dt, to_dt, period_label)
pdf_bytes = _generate_pdf_bytes(report)
filename = f"smart-city-report-{type}-{filename_date}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)