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,183 @@
# Smart App City — Backend Architecture Proposal
## Overview
Backend Python FastAPI + JWT pour l'application mobile Smart City Martinique.
## Architecture
```
api-gateway/ → Point d'entrée unique, rate limiting, CORS
auth-service/ → Inscription, login, JWT, gestion utilisateurs
iot-service/ → Données capteurs, alertes, historique
gis-service/ → Données géospatiales, couches, géocodage
notification-service/ → Notifications push, alertes
reporting-service/ → Rapports, statistiques, export PDF
```
## Tech Stack
- **Framework** : FastAPI (async, OpenAPI auto, Pydantic)
- **Auth** : JWT (python-jose) + bcrypt (passlib)
- **DB** : PostgreSQL (existant) + SQLModel (ORM)
- **Cache** : Redis (existant)
- **Messages** : MQTT (Mosquitto existant) pour temps réel
- **Notifications** : Firebase Cloud Messaging (FCM)
- **Maps** : Tuiles vectorielles OpenStreetMap
## API Endpoints (MVP)
### Auth Service (`/api/v1/auth`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/register` | Inscription utilisateur |
| POST | `/login` | Login → JWT tokens |
| POST | `/refresh` | Refresh access token |
| GET | `/me` | Profil utilisateur |
| PUT | `/me` | Modifier profil |
| POST | `/logout` | Invalider token |
### IoT Service (`/api/v1/iot`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/sensors` | Liste des capteurs |
| GET | `/sensors/{id}` | Détail capteur |
| GET | `/sensors/{id}/data` | Données historiques |
| GET | `/zones` | Zones de la ville |
| GET | `/zones/{id}/sensors` | Capteurs par zone |
| GET | `/alerts` | Alertes actives |
| GET | `/stats` | Statistiques globales |
### GIS Service (`/api/v1/gis`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/layers` | Couches disponibles |
| GET | `/layers/{id}/features` | Features d'une couche |
| GET | `/search` | Recherche d'adresse |
| GET | `/reverse` | Géocodage inversé |
### Reporting Service (`/api/v1/reports`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/daily` | Rapport journalier |
| GET | `/weekly` | Rapport hebdomadaire |
| GET | `/custom` | Rapport personnalisé |
| GET | `/export/pdf` | Export PDF |
### Notification Service (`/api/v1/notifications`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/` | Notifications utilisateur |
| PUT | `/read/{id}` | Marquer comme lu |
| POST | `/register-device` | Enregistrer token FCM |
| GET | `/preferences` | Préférences notifications |
## Data Models
### User
```python
class User(BaseModel):
id: UUID
email: str
username: str
hashed_password: str
first_name: str
last_name: str
phone: Optional[str]
role: UserRole # admin, user, viewer
avatar_url: Optional[str]
created_at: datetime
updated_at: datetime
```
### Sensor
```python
class Sensor(BaseModel):
id: UUID
name: str
type: SensorType # temperature, humidity, air_quality, traffic, noise
status: SensorStatus # active, inactive, maintenance, error
location: GeoPoint # [lng, lat]
zone_id: Optional[UUID]
last_reading: Optional[Reading]
battery_level: Optional[int]
created_at: datetime
```
### Reading
```python
class Reading(BaseModel):
id: UUID
sensor_id: UUID
value: float
unit: str
timestamp: datetime
quality: float # 0-1 data quality score
```
### Alert
```python
class Alert(BaseModel):
id: UUID
sensor_id: UUID
type: AlertType # threshold_offline, anomaly
severity: AlertSeverity # low, medium, high, critical
message: str
value: Optional[float]
threshold: Optional[float]
status: AlertStatus # active, acknowledged, resolved
created_at: datetime
resolved_at: Optional[datetime]
```
## Services Structure (per service)
Each service follows this structure:
```
service-name/
├── Dockerfile
├── requirements.txt
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry
│ ├── config.py # Settings, env vars
│ ├── database.py # DB connection, session
│ ├── models/ # SQLModel models
│ │ ├── __init__.py
│ │ └── models.py
│ ├── schemas/ # Pydantic schemas
│ │ ├── __init__.py
│ │ └── schemas.py
│ ├── routes/ # API routes
│ │ ├── __init__.py
│ │ └── routes.py
│ ├── services/ # Business logic
│ │ ├── __init__.py
│ │ └── service.py
│ └── auth/ # JWT verification
│ ├── __init__.py
│ └── jwt.py
└── tests/
├── __init__.py
└── test_routes.py
```
## Deployment Options
### Option A: Single Container (simplifié)
Tous les services dans un seul container FastAPI avec des routers séparés.
- Plus simple à déployer
- Moins de ressources
- Idéal pour MVP
### Option B: Multi-containers (microservices réel)
Chaque service dans son propre container.
- Plus scalable
- Plus complexe à gérer
- Nécessite Docker Compose
**Recommandation MVP** : Option A (single container avec routers), puis refactor en microservices si besoin.
## Next Steps
1. Choisir Option A ou B
2. Créer le backend complet
3. Intégrer avec le frontend Expo existant
4. Déployer sur le serveur

View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

View File

View File

@@ -0,0 +1,67 @@
"""JWT authentication utilities — token creation, validation, and dependency."""
from datetime import datetime, timedelta, timezone
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.config import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
SECRET_KEY = settings.JWT_SECRET
ALGORITHM = settings.JWT_ALGORITHM
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
REFRESH_TOKEN_EXPIRE_DAYS = settings.REFRESH_TOKEN_EXPIRE_DAYS
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token.
Encodes *data* into a JWT signed with SECRET_KEY.
If *expires_delta* is omitted, ACCESS_TOKEN_EXPIRE_MINUTES is used.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
"""Create a JWT refresh token valid for REFRESH_TOKEN_EXPIRE_DAYS days."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
"""Decode and verify a JWT.
Raises HTTPException 401 if the token is invalid or expired.
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
"""FastAPI dependency that extracts the current user from a Bearer JWT."""
payload = verify_token(token)
return payload

View File

@@ -0,0 +1,30 @@
"""Application configuration via pydantic-settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# Database
DATABASE_URL: str = "postgresql+asyncpg://lakehouse:lakehouse123@postgres-meta/lakehouse_meta"
# Redis
REDIS_URL: str = "redis://redis:6379/0"
# JWT
JWT_SECRET: str = "CHANGE_ME_IN_PRODUCTION"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# MQTT
MQTT_BROKER: str = "smart-city-digital-twin-martinique-mosquitto-1"
MQTT_PORT: int = 1883
# CORS
CORS_ORIGINS: List[str] = ["*"]
settings = Settings()

View File

@@ -0,0 +1,57 @@
"""Async SQLAlchemy database setup for Smart City Digital Twin."""
from collections.abc import AsyncGenerator
from typing import NoReturn
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# ── Declarative base ──────────────────────────────────────────────────
class Base(DeclarativeBase):
pass
# ── Engine ───────────────────────────────────────────────────────────
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_size=10,
max_overflow=20,
)
# ── Session factory ──────────────────────────────────────────────────
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# ── Dependency: per-request session ──────────────────────────────────
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async session and close it when the request is done."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
# ── Startup: create tables ───────────────────────────────────────────
async def init_db() -> None:
"""Create all tables defined via ``Base`` models (idempotent)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# ── Shutdown: release connections ────────────────────────────────────
async def close_db() -> None:
"""Dispose the engine and release all pooled connections."""
await engine.dispose()

View File

@@ -0,0 +1,50 @@
"""
Smart App City — Main Application
FastAPI backend avec JWT, IoT, GIS, Notifications, Reporting
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import init_db, close_db
from app.routes.auth import router as auth_router
from app.routes.iot import router as iot_router
from app.routes.gis import router as gis_router
from app.routes.notifications import router as notifications_router
from app.routes.reporting import router as reporting_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
await init_db()
yield
await close_db()
app = FastAPI(
title="Smart App City",
description="Backend API pour l'application mobile Smart City Digital Twin Martinique",
version="0.1.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
app.include_router(auth_router, prefix="/api/v1")
app.include_router(iot_router, prefix="/api/v1")
app.include_router(gis_router, prefix="/api/v1")
app.include_router(notifications_router, prefix="/api/v1")
app.include_router(reporting_router, prefix="/api/v1")
@app.get("/health", tags=["health"])
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "version": "0.1.0"}

View File

@@ -0,0 +1,120 @@
"""SQLModels for Smart City Digital Twin — Martinique."""
import uuid
from datetime import datetime
from typing import Optional
from uuid import UUID
from sqlmodel import Field, Relationship, SQLModel
class User(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
email: str = Field(unique=True, index=True)
username: str = Field(unique=True, index=True)
hashed_password: str
first_name: str
last_name: str
phone: Optional[str] = Field(default=None)
role: str = Field(default="user")
avatar_url: Optional[str] = Field(default=None)
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
alerts: list["Alert"] = Relationship(back_populates="owner")
notification_devices: list["NotificationDevice"] = Relationship(
back_populates="user"
)
class Zone(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True)
description: Optional[str] = Field(default=None)
color: str = Field(default="#3949AB")
geojson: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
sensors: list["Sensor"] = Relationship(back_populates="zone")
class Sensor(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
name: str = Field(index=True)
type: str
status: str = Field(default="active")
latitude: float
longitude: float
zone_id: Optional[UUID] = Field(default=None, foreign_key="zone.id")
last_value: Optional[float] = Field(default=None)
last_reading_at: Optional[datetime] = Field(default=None)
battery_level: Optional[int] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
zone: Optional[Zone] = Relationship(back_populates="sensors")
readings: list["SensorReading"] = Relationship(back_populates="sensor")
alerts: list["Alert"] = Relationship(back_populates="sensor")
class SensorReading(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
sensor_id: UUID = Field(foreign_key="sensor.id")
value: float
unit: str
quality: float = Field(default=1.0)
recorded_at: datetime
created_at: datetime = Field(default_factory=datetime.utcnow)
sensor: Sensor = Relationship(back_populates="readings")
class Alert(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
sensor_id: UUID = Field(foreign_key="sensor.id")
type: str
severity: str = Field(default="medium")
message: str
value: Optional[float] = Field(default=None)
threshold: Optional[float] = Field(default=None)
status: str = Field(default="active")
created_at: datetime = Field(default_factory=datetime.utcnow)
resolved_at: Optional[datetime] = Field(default=None)
sensor: Sensor = Relationship(back_populates="alerts")
class NotificationDevice(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
platform: str
push_token: str = Field(unique=True)
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship(back_populates="notification_devices")
class Notification(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id")
title: str
body: str
category: str = Field(default="general") # general, alert, maintenance, event
is_read: bool = Field(default=False)
data: Optional[str] = Field(default=None) # JSON payload as string
created_at: datetime = Field(default_factory=datetime.utcnow)
read_at: Optional[datetime] = Field(default=None)
class NotificationPreference(SQLModel, table=True):
id: UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="user.id", unique=True)
push_enabled: bool = Field(default=True)
email_enabled: bool = Field(default=False)
alert_notifications: bool = Field(default=True)
maintenance_notifications: bool = Field(default=True)
event_notifications: bool = Field(default=True)
general_notifications: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

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}"',
},
)

View File

@@ -0,0 +1,147 @@
"""Pydantic schemas for request/response validation.
All models are plain Pydantic BaseModel (not SQLModel) so they can be
used purely for API I/O without DB-session side-effects.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Generic, List, Optional, TypeVar
from uuid import UUID
from pydantic import BaseModel, Field
# ──────────────────────────────────────────────────────────────────────
# User
# ──────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
username: str = Field(min_length=3, max_length=50)
first_name: str
last_name: str
phone: Optional[str] = None
class UserLogin(BaseModel):
email: str
password: str
class UserResponse(BaseModel):
id: UUID
email: str
username: str
first_name: str
last_name: str
phone: Optional[str] = None
role: str
avatar_url: Optional[str] = None
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
# ──────────────────────────────────────────────────────────────────────
# Auth / Token
# ──────────────────────────────────────────────────────────────────────
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# ──────────────────────────────────────────────────────────────────────
# Sensor
# ──────────────────────────────────────────────────────────────────────
class SensorResponse(BaseModel):
id: UUID
name: str
type: str
status: str
latitude: float
longitude: float
zone_id: Optional[UUID] = None
last_value: Optional[float] = None
last_reading_at: Optional[datetime] = None
battery_level: Optional[int] = None
model_config = {"from_attributes": True}
class SensorReadingResponse(BaseModel):
id: UUID
sensor_id: UUID
value: float
unit: str
quality: float
recorded_at: datetime
model_config = {"from_attributes": True}
# ──────────────────────────────────────────────────────────────────────
# Zone
# ──────────────────────────────────────────────────────────────────────
class ZoneResponse(BaseModel):
id: UUID
name: str
description: Optional[str] = None
color: str
geojson: Optional[str] = None
model_config = {"from_attributes": True}
# ──────────────────────────────────────────────────────────────────────
# Alert
# ──────────────────────────────────────────────────────────────────────
class AlertResponse(BaseModel):
id: UUID
sensor_id: UUID
type: str
severity: str
message: str
value: Optional[float] = None
threshold: Optional[float] = None
status: str
created_at: datetime
model_config = {"from_attributes": True}
# ──────────────────────────────────────────────────────────────────────
# Dashboard
# ──────────────────────────────────────────────────────────────────────
class DashboardStats(BaseModel):
total_sensors: int
active_sensors: int
total_readings_24h: int
active_alerts: int
avg_air_quality: Optional[float] = None
avg_temperature: Optional[float] = None
avg_humidity: Optional[float] = None
# ──────────────────────────────────────────────────────────────────────
# Pagination (generic)
# ──────────────────────────────────────────────────────────────────────
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
page: int
page_size: int
pages: int

View File

@@ -0,0 +1,15 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
pydantic==2.7.0
pydantic-settings==2.3.0
sqlmodel==0.0.18
sqlalchemy==2.0.30
asyncpg==0.29.0
alembic==1.13.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
redis[hiredis]==5.0.0
httpx==0.27.0
pytest==8.2.0
pytest-asyncio==0.23.0