feat: backend FastAPI Smart App City — auth JWT, IoT, GIS, notifications, reporting
This commit is contained in:
183
smart-app-city/backend/ARCHITECTURE.md
Normal file
183
smart-app-city/backend/ARCHITECTURE.md
Normal 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
|
||||
16
smart-app-city/backend/Dockerfile
Normal file
16
smart-app-city/backend/Dockerfile
Normal 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"]
|
||||
0
smart-app-city/backend/app/__init__.py
Normal file
0
smart-app-city/backend/app/__init__.py
Normal file
BIN
smart-app-city/backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
smart-app-city/backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
smart-app-city/backend/app/auth/__init__.py
Normal file
0
smart-app-city/backend/app/auth/__init__.py
Normal file
67
smart-app-city/backend/app/auth/jwt.py
Normal file
67
smart-app-city/backend/app/auth/jwt.py
Normal 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
|
||||
30
smart-app-city/backend/app/config.py
Normal file
30
smart-app-city/backend/app/config.py
Normal 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()
|
||||
57
smart-app-city/backend/app/database.py
Normal file
57
smart-app-city/backend/app/database.py
Normal 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()
|
||||
50
smart-app-city/backend/app/main.py
Normal file
50
smart-app-city/backend/app/main.py
Normal 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"}
|
||||
0
smart-app-city/backend/app/middleware/__init__.py
Normal file
0
smart-app-city/backend/app/middleware/__init__.py
Normal file
0
smart-app-city/backend/app/models/__init__.py
Normal file
0
smart-app-city/backend/app/models/__init__.py
Normal file
120
smart-app-city/backend/app/models/models.py
Normal file
120
smart-app-city/backend/app/models/models.py
Normal 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)
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
0
smart-app-city/backend/app/schemas/__init__.py
Normal file
0
smart-app-city/backend/app/schemas/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
147
smart-app-city/backend/app/schemas/schemas.py
Normal file
147
smart-app-city/backend/app/schemas/schemas.py
Normal 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
|
||||
0
smart-app-city/backend/app/services/__init__.py
Normal file
0
smart-app-city/backend/app/services/__init__.py
Normal file
15
smart-app-city/backend/requirements.txt
Normal file
15
smart-app-city/backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user