diff --git a/smart-app-city/backend/ARCHITECTURE.md b/smart-app-city/backend/ARCHITECTURE.md new file mode 100644 index 00000000..a543df00 --- /dev/null +++ b/smart-app-city/backend/ARCHITECTURE.md @@ -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 diff --git a/smart-app-city/backend/Dockerfile b/smart-app-city/backend/Dockerfile new file mode 100644 index 00000000..e8ba879e --- /dev/null +++ b/smart-app-city/backend/Dockerfile @@ -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"] diff --git a/smart-app-city/backend/app/__init__.py b/smart-app-city/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/__pycache__/__init__.cpython-313.pyc b/smart-app-city/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..3946ae1f Binary files /dev/null and b/smart-app-city/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/smart-app-city/backend/app/auth/__init__.py b/smart-app-city/backend/app/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/auth/jwt.py b/smart-app-city/backend/app/auth/jwt.py new file mode 100644 index 00000000..6a37e9c9 --- /dev/null +++ b/smart-app-city/backend/app/auth/jwt.py @@ -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 diff --git a/smart-app-city/backend/app/config.py b/smart-app-city/backend/app/config.py new file mode 100644 index 00000000..b6b3e592 --- /dev/null +++ b/smart-app-city/backend/app/config.py @@ -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() diff --git a/smart-app-city/backend/app/database.py b/smart-app-city/backend/app/database.py new file mode 100644 index 00000000..15ad0fcd --- /dev/null +++ b/smart-app-city/backend/app/database.py @@ -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() diff --git a/smart-app-city/backend/app/main.py b/smart-app-city/backend/app/main.py new file mode 100644 index 00000000..dc197b0a --- /dev/null +++ b/smart-app-city/backend/app/main.py @@ -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"} diff --git a/smart-app-city/backend/app/middleware/__init__.py b/smart-app-city/backend/app/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/models/__init__.py b/smart-app-city/backend/app/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/models/models.py b/smart-app-city/backend/app/models/models.py new file mode 100644 index 00000000..50d786c7 --- /dev/null +++ b/smart-app-city/backend/app/models/models.py @@ -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) diff --git a/smart-app-city/backend/app/routes/__init__.py b/smart-app-city/backend/app/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/routes/auth.py b/smart-app-city/backend/app/routes/auth.py new file mode 100644 index 00000000..d995552c --- /dev/null +++ b/smart-app-city/backend/app/routes/auth.py @@ -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) diff --git a/smart-app-city/backend/app/routes/gis.py b/smart-app-city/backend/app/routes/gis.py new file mode 100644 index 00000000..239892fa --- /dev/null +++ b/smart-app-city/backend/app/routes/gis.py @@ -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), + } diff --git a/smart-app-city/backend/app/routes/iot.py b/smart-app-city/backend/app/routes/iot.py new file mode 100644 index 00000000..ebff15cb --- /dev/null +++ b/smart-app-city/backend/app/routes/iot.py @@ -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 diff --git a/smart-app-city/backend/app/routes/notifications.py b/smart-app-city/backend/app/routes/notifications.py new file mode 100644 index 00000000..009ecc18 --- /dev/null +++ b/smart-app-city/backend/app/routes/notifications.py @@ -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, + } diff --git a/smart-app-city/backend/app/routes/reporting.py b/smart-app-city/backend/app/routes/reporting.py new file mode 100644 index 00000000..df2960b6 --- /dev/null +++ b/smart-app-city/backend/app/routes/reporting.py @@ -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}"', + }, + ) diff --git a/smart-app-city/backend/app/schemas/__init__.py b/smart-app-city/backend/app/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/app/schemas/__pycache__/__init__.cpython-313.pyc b/smart-app-city/backend/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 00000000..64261475 Binary files /dev/null and b/smart-app-city/backend/app/schemas/__pycache__/__init__.cpython-313.pyc differ diff --git a/smart-app-city/backend/app/schemas/__pycache__/schemas.cpython-313.pyc b/smart-app-city/backend/app/schemas/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 00000000..1a4b1698 Binary files /dev/null and b/smart-app-city/backend/app/schemas/__pycache__/schemas.cpython-313.pyc differ diff --git a/smart-app-city/backend/app/schemas/schemas.py b/smart-app-city/backend/app/schemas/schemas.py new file mode 100644 index 00000000..3e331574 --- /dev/null +++ b/smart-app-city/backend/app/schemas/schemas.py @@ -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 diff --git a/smart-app-city/backend/app/services/__init__.py b/smart-app-city/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/smart-app-city/backend/requirements.txt b/smart-app-city/backend/requirements.txt new file mode 100644 index 00000000..b4d939a2 --- /dev/null +++ b/smart-app-city/backend/requirements.txt @@ -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