feat: backend FastAPI Smart App City — auth JWT, IoT, GIS, notifications, reporting
This commit is contained in:
0
smart-app-city/backend/app/routes/__init__.py
Normal file
0
smart-app-city/backend/app/routes/__init__.py
Normal file
319
smart-app-city/backend/app/routes/auth.py
Normal file
319
smart-app-city/backend/app/routes/auth.py
Normal 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)
|
||||
443
smart-app-city/backend/app/routes/gis.py
Normal file
443
smart-app-city/backend/app/routes/gis.py
Normal 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),
|
||||
}
|
||||
511
smart-app-city/backend/app/routes/iot.py
Normal file
511
smart-app-city/backend/app/routes/iot.py
Normal 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
|
||||
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,
|
||||
}
|
||||
570
smart-app-city/backend/app/routes/reporting.py
Normal file
570
smart-app-city/backend/app/routes/reporting.py
Normal 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 (Monday–Sunday).",
|
||||
)
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user