app backend prima

This commit is contained in:
2025-10-20 19:10:08 +02:00
commit 438255d27b
42 changed files with 4622 additions and 0 deletions

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

171
app/api/allarmi.py Normal file
View File

@@ -0,0 +1,171 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.core.database import get_db
from app.models import Allarme, Utente, Sito
from app.schemas.allarme import AllarmeResponse, AllarmeList, AllarmeUpdate
from app.api.auth import get_current_user
router = APIRouter(prefix="/allarmi", tags=["Allarmi"])
@router.get("", response_model=AllarmeList)
async def get_allarmi(
current_user: Annotated[Utente, Depends(get_current_user)],
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
sito_id: Optional[int] = None,
severita: Optional[str] = None,
stato: Optional[str] = None,
):
"""
Recupera gli allarmi per il cliente dell'utente autenticato.
Supporta filtri e paginazione.
"""
# Query base: solo allarmi dei siti del cliente dell'utente
query = (
db.query(Allarme)
.join(Sito)
.filter(Sito.cliente_id == current_user.cliente_id)
)
# Applica filtri opzionali
if sito_id:
query = query.filter(Allarme.sito_id == sito_id)
if severita:
query = query.filter(Allarme.severita == severita)
if stato:
query = query.filter(Allarme.stato == stato)
# Conta totale
total = query.count()
# Applica paginazione e ordinamento
allarmi = (
query.order_by(desc(Allarme.timestamp_rilevamento))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return AllarmeList(
total=total,
items=allarmi,
page=page,
page_size=page_size,
)
@router.get("/{allarme_id}", response_model=AllarmeResponse)
async def get_allarme(
allarme_id: int,
current_user: Annotated[Utente, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""Recupera un singolo allarme per ID"""
allarme = (
db.query(Allarme)
.join(Sito)
.filter(
Allarme.id == allarme_id,
Sito.cliente_id == current_user.cliente_id
)
.first()
)
if not allarme:
raise HTTPException(status_code=404, detail="Allarme non trovato")
return allarme
@router.patch("/{allarme_id}", response_model=AllarmeResponse)
async def update_allarme(
allarme_id: int,
update_data: AllarmeUpdate,
current_user: Annotated[Utente, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""Aggiorna lo stato o le note di un allarme"""
allarme = (
db.query(Allarme)
.join(Sito)
.filter(
Allarme.id == allarme_id,
Sito.cliente_id == current_user.cliente_id
)
.first()
)
if not allarme:
raise HTTPException(status_code=404, detail="Allarme non trovato")
# Aggiorna campi se forniti
if update_data.stato is not None:
allarme.stato = update_data.stato
# Se viene risolto, registra chi e quando
if update_data.stato == "risolto":
from datetime import datetime, timezone
allarme.risolto_da = f"{current_user.nome} {current_user.cognome}"
allarme.timestamp_risoluzione = datetime.now(timezone.utc)
if update_data.note is not None:
allarme.note = update_data.note
if update_data.risolto_da is not None:
allarme.risolto_da = update_data.risolto_da
db.commit()
db.refresh(allarme)
return allarme
@router.get("/sito/{sito_id}", response_model=AllarmeList)
async def get_allarmi_by_sito(
sito_id: int,
current_user: Annotated[Utente, Depends(get_current_user)],
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
):
"""Recupera tutti gli allarmi per un sito specifico"""
# Verifica che il sito appartenga al cliente dell'utente
sito = (
db.query(Sito)
.filter(
Sito.id == sito_id,
Sito.cliente_id == current_user.cliente_id
)
.first()
)
if not sito:
raise HTTPException(status_code=404, detail="Sito non trovato")
query = db.query(Allarme).filter(Allarme.sito_id == sito_id)
total = query.count()
allarmi = (
query.order_by(desc(Allarme.timestamp_rilevamento))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return AllarmeList(
total=total,
items=allarmi,
page=page,
page_size=page_size,
)

130
app/api/auth.py Normal file
View File

@@ -0,0 +1,130 @@
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, decode_access_token
from app.models import Utente
from app.schemas.auth import Token, LoginRequest, RegisterFCMToken
router = APIRouter(prefix="/auth", tags=["Authentication"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Session = Depends(get_db)
) -> Utente:
"""Dependency per ottenere l'utente corrente dal token JWT"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenziali non valide",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
email: str = payload.get("sub")
if email is None:
raise credentials_exception
user = db.query(Utente).filter(Utente.email == email).first()
if user is None:
raise credentials_exception
if not user.attivo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Utente non attivo"
)
return user
@router.post("/token", response_model=Token)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Session = Depends(get_db)
):
"""Endpoint per login con OAuth2 password flow"""
user = db.query(Utente).filter(Utente.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o password non corretti",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.attivo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Utente non attivo"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email, "cliente_id": user.cliente_id},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/login", response_model=Token)
async def login_json(login_data: LoginRequest, db: Session = Depends(get_db)):
"""Endpoint per login con JSON"""
user = db.query(Utente).filter(Utente.email == login_data.email).first()
if not user or not verify_password(login_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email o password non corretti"
)
if not user.attivo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Utente non attivo"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email, "cliente_id": user.cliente_id},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register-fcm-token")
async def register_fcm_token(
token_data: RegisterFCMToken,
current_user: Annotated[Utente, Depends(get_current_user)],
db: Session = Depends(get_db)
):
"""Registra o aggiorna il FCM token per l'utente corrente"""
current_user.fcm_token = token_data.fcm_token
db.commit()
return {"message": "FCM token registrato con successo"}
@router.get("/me")
async def get_me(current_user: Annotated[Utente, Depends(get_current_user)]):
"""Restituisce le informazioni dell'utente corrente"""
return {
"id": current_user.id,
"email": current_user.email,
"nome": current_user.nome,
"cognome": current_user.cognome,
"ruolo": current_user.ruolo,
"cliente_id": current_user.cliente_id,
}

50
app/api/siti.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Endpoint API per la gestione dei siti monitorati
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import Sito, Utente
from app.api.auth import get_current_user
from app.schemas.sito import SitoResponse, SitoListResponse
router = APIRouter(prefix="/siti", tags=["siti"])
@router.get("/{sito_id}", response_model=SitoResponse)
async def get_sito(
sito_id: int,
current_user: Utente = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Recupera dettagli di un sito specifico"""
sito = db.query(Sito).filter(
Sito.id == sito_id,
Sito.cliente_id == current_user.cliente_id,
).first()
if not sito:
raise HTTPException(status_code=404, detail="Sito non trovato")
return sito
@router.get("", response_model=SitoListResponse)
async def get_siti(
skip: int = 0,
limit: int = 100,
tipo: str | None = None,
current_user: Utente = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Recupera lista siti del cliente"""
query = db.query(Sito).filter(Sito.cliente_id == current_user.cliente_id)
if tipo:
query = query.filter(Sito.tipo == tipo)
total = query.count()
siti = query.offset(skip).limit(limit).all()
return {"total": total, "items": siti}

127
app/api/statistiche.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Endpoint API per statistiche e dashboard
"""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import Allarme, Sito, Utente
from app.api.auth import get_current_user
from app.schemas.statistiche import StatisticheResponse, AllarmiPerGiornoResponse
router = APIRouter(prefix="/statistiche", tags=["statistiche"])
@router.get("", response_model=StatisticheResponse)
async def get_statistiche(
current_user: Utente = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Recupera statistiche generali per il dashboard"""
# Totali
totale_allarmi = db.query(Allarme).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id
).count()
totale_siti = db.query(Sito).filter(
Sito.cliente_id == current_user.cliente_id
).count()
# Allarmi per severità
allarmi_per_severita = db.query(
Allarme.severita,
func.count(Allarme.id).label('count')
).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id
).group_by(Allarme.severita).all()
severita_dict = {s: c for s, c in allarmi_per_severita}
# Allarmi per stato
allarmi_per_stato = db.query(
Allarme.stato,
func.count(Allarme.id).label('count')
).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id
).group_by(Allarme.stato).all()
stato_dict = {s: c for s, c in allarmi_per_stato}
# Allarmi aperti (non risolti)
allarmi_aperti = db.query(Allarme).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id,
Allarme.stato != 'risolto'
).count()
# Allarmi ultimi 7 giorni
seven_days_ago = datetime.now() - timedelta(days=7)
allarmi_recenti = db.query(Allarme).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id,
Allarme.created_at >= seven_days_ago
).count()
# Siti per tipo
siti_per_tipo = db.query(
Sito.tipo,
func.count(Sito.id).label('count')
).filter(
Sito.cliente_id == current_user.cliente_id
).group_by(Sito.tipo).all()
tipo_dict = {t: c for t, c in siti_per_tipo}
return {
"totale_allarmi": totale_allarmi,
"totale_siti": totale_siti,
"allarmi_aperti": allarmi_aperti,
"allarmi_recenti_7gg": allarmi_recenti,
"allarmi_critical": severita_dict.get('critical', 0),
"allarmi_warning": severita_dict.get('warning', 0),
"allarmi_info": severita_dict.get('info', 0),
"allarmi_nuovo": stato_dict.get('nuovo', 0),
"allarmi_in_gestione": stato_dict.get('in_gestione', 0),
"allarmi_risolto": stato_dict.get('risolto', 0),
"siti_ponte": tipo_dict.get('ponte', 0),
"siti_galleria": tipo_dict.get('galleria', 0),
"siti_diga": tipo_dict.get('diga', 0),
"siti_frana": tipo_dict.get('frana', 0),
"siti_versante": tipo_dict.get('versante', 0),
"siti_edificio": tipo_dict.get('edificio', 0),
}
@router.get("/allarmi-per-giorno", response_model=AllarmiPerGiornoResponse)
async def get_allarmi_per_giorno(
giorni: int = 30,
current_user: Utente = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Recupera statistiche allarmi per giorno (per grafici temporali)"""
start_date = datetime.now() - timedelta(days=giorni)
# Query allarmi raggruppati per giorno
allarmi_giornalieri = db.query(
func.date(Allarme.created_at).label('data'),
func.count(Allarme.id).label('count')
).join(Sito).filter(
Sito.cliente_id == current_user.cliente_id,
Allarme.created_at >= start_date
).group_by(
func.date(Allarme.created_at)
).order_by(
func.date(Allarme.created_at)
).all()
# Converti in lista di dict
dati = []
for data, count in allarmi_giornalieri:
dati.append({
"data": data.isoformat(),
"count": count
})
return {"dati": dati}

0
app/core/__init__.py Normal file
View File

44
app/core/config.py Normal file
View File

@@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path
# Determina il percorso del file .env (nella root del progetto)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
ENV_FILE = BASE_DIR / ".env"
class Settings(BaseSettings):
"""Configurazione applicazione caricata da variabili d'ambiente"""
# Database
DATABASE_URL: str = "postgresql://user:password@localhost:5432/terrain_monitor"
# JWT
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# MQTT
MQTT_BROKER_HOST: str = "localhost"
MQTT_BROKER_PORT: int = 1883
# Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
# Wildcards: + (single level), # (multi level)
MQTT_TOPIC_ALARMS: str = "terrain/+/+/alarms"
MQTT_TOPIC_TELEMETRY: str = "terrain/+/+/telemetry" # Dati sensori periodici
MQTT_TOPIC_STATUS: str = "terrain/+/+/status" # Stato sensori/gateway
MQTT_USERNAME: str = ""
MQTT_PASSWORD: str = ""
# Firebase
FIREBASE_CREDENTIALS_PATH: str = "./firebase-credentials.json"
# Application
DEBUG: bool = False
APP_NAME: str = "Terrain Monitor API"
model_config = SettingsConfigDict(
env_file=str(ENV_FILE),
env_file_encoding='utf-8'
)
settings = Settings()

19
app/core/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency per ottenere una sessione database"""
db = SessionLocal()
try:
yield db
finally:
db.close()

45
app/core/firebase.py Normal file
View File

@@ -0,0 +1,45 @@
import logging
import firebase_admin
from firebase_admin import credentials
from pathlib import Path
from app.core.config import settings
logger = logging.getLogger(__name__)
firebase_app = None
def init_firebase():
"""Inizializza Firebase Admin SDK"""
global firebase_app
if firebase_app is not None:
logger.info("Firebase già inizializzato")
return firebase_app
try:
# Percorso al file di credenziali
cred_path = Path(__file__).parent.parent.parent / "firebase-credentials.json"
if not cred_path.exists():
logger.error(f"File credenziali Firebase non trovato: {cred_path}")
raise FileNotFoundError(f"firebase-credentials.json non trovato in {cred_path}")
# Inizializza con le credenziali
cred = credentials.Certificate(str(cred_path))
firebase_app = firebase_admin.initialize_app(cred)
logger.info("Firebase Admin SDK inizializzato con successo")
return firebase_app
except Exception as e:
logger.error(f"Errore nell'inizializzazione di Firebase: {e}")
raise
def get_firebase_app():
"""Ottiene l'istanza di Firebase App"""
if firebase_app is None:
return init_firebase()
return firebase_app

46
app/core/security.py Normal file
View File

@@ -0,0 +1,46 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifica una password contro il suo hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Genera l'hash di una password"""
# bcrypt ha un limite di 72 byte, tronchiamo se necessario
# Questo è sicuro perché 72 byte forniscono comunque entropia sufficiente
if len(password.encode('utf-8')) > 72:
password = password.encode('utf-8')[:72].decode('utf-8', errors='ignore')
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Crea un JWT token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""Decodifica e valida un JWT token"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None

107
app/main.py Normal file
View File

@@ -0,0 +1,107 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.database import Base, engine
from app.api import auth, allarmi, siti, statistiche
from app.mqtt.client import MQTTClient
from app.mqtt.handler import alarm_handler
# Configurazione logging
logging.basicConfig(
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Client MQTT globale
mqtt_client = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Gestione lifecycle dell'applicazione"""
global mqtt_client
# Startup
logger.info("Avvio applicazione...")
# Crea tabelle database
logger.info("Creazione tabelle database...")
Base.metadata.create_all(bind=engine)
# Avvia client MQTT
logger.info("Avvio client MQTT...")
mqtt_client = MQTTClient(message_handler=alarm_handler.handle_alarm)
try:
mqtt_client.start()
logger.info("Client MQTT avviato con successo")
except Exception as e:
logger.error(f"Errore nell'avvio del client MQTT: {e}")
logger.warning("L'applicazione continuerà senza il client MQTT")
yield
# Shutdown
logger.info("Arresto applicazione...")
if mqtt_client:
mqtt_client.stop()
logger.info("Client MQTT fermato")
# Crea applicazione FastAPI
app = FastAPI(
title=settings.APP_NAME,
description="API per il sistema di monitoraggio terreni con notifiche push",
version="1.0.0",
lifespan=lifespan,
)
# Configurazione CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In produzione, specificare i domini permessi
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Registrazione router
app.include_router(auth.router)
app.include_router(allarmi.router)
app.include_router(siti.router)
app.include_router(statistiche.router)
@app.get("/")
async def root():
"""Endpoint root per verificare che l'API sia attiva"""
return {
"message": "Terrain Monitor API",
"version": "1.0.0",
"status": "active",
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"mqtt_connected": mqtt_client.client.is_connected() if mqtt_client else False,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
)

16
app/models/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
from app.models.cliente import Cliente
from app.models.sito import Sito, TipoSito
from app.models.utente import Utente, RuoloUtente
from app.models.allarme import Allarme, SeveritaAllarme, StatoAllarme, TipoAllarme
__all__ = [
"Cliente",
"Sito",
"TipoSito",
"Utente",
"RuoloUtente",
"Allarme",
"SeveritaAllarme",
"StatoAllarme",
"TipoAllarme",
]

78
app/models/allarme.py Normal file
View File

@@ -0,0 +1,78 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Enum as SQLEnum, JSON, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.core.database import Base
class SeveritaAllarme(str, enum.Enum):
"""Livelli di severità degli allarmi"""
CRITICAL = "critical" # Critico - richiede azione immediata
WARNING = "warning" # Avviso - richiede attenzione
INFO = "info" # Informativo
class StatoAllarme(str, enum.Enum):
"""Stati di un allarme"""
NUOVO = "nuovo"
VISUALIZZATO = "visualizzato"
IN_GESTIONE = "in_gestione"
RISOLTO = "risolto"
FALSO_POSITIVO = "falso_positivo"
class TipoAllarme(str, enum.Enum):
"""Tipologie di allarmi"""
MOVIMENTO_TERRENO = "movimento_terreno"
DEFORMAZIONE = "deformazione"
ACCELERAZIONE = "accelerazione"
INCLINAZIONE = "inclinazione"
FESSURAZIONE = "fessurazione"
VIBRAZIONE = "vibrazione"
TEMPERATURA_ANOMALA = "temperatura_anomala"
UMIDITA_ANOMALA = "umidita_anomala"
PERDITA_SEGNALE = "perdita_segnale"
BATTERIA_SCARICA = "batteria_scarica"
ALTRO = "altro"
class Allarme(Base):
"""Modello per gli allarmi generati dal sistema di monitoraggio"""
__tablename__ = "allarmi"
id = Column(Integer, primary_key=True, index=True)
sito_id = Column(Integer, ForeignKey("siti.id"), nullable=False)
# Classificazione allarme
tipo = Column(SQLEnum(TipoAllarme), nullable=False)
severita = Column(SQLEnum(SeveritaAllarme), nullable=False, index=True)
stato = Column(SQLEnum(StatoAllarme), default=StatoAllarme.NUOVO, index=True)
# Dettagli allarme
titolo = Column(String(255), nullable=False)
descrizione = Column(Text)
messaggio = Column(Text)
# Dati sensori (JSON con i valori rilevati)
dati_sensori = Column(JSON)
# Valori soglia
valore_rilevato = Column(Float)
valore_soglia = Column(Float)
unita_misura = Column(String(20))
# Metadata temporale
timestamp_rilevamento = Column(DateTime(timezone=True), nullable=False, index=True)
timestamp_notifica = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Note operative
note = Column(Text)
risolto_da = Column(String(255))
timestamp_risoluzione = Column(DateTime(timezone=True))
# Relazioni
sito = relationship("Sito", back_populates="allarmi")

27
app/models/cliente.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Cliente(Base):
"""Modello per i clienti che possiedono siti monitorati"""
__tablename__ = "clienti"
id = Column(Integer, primary_key=True, index=True)
nome = Column(String(255), nullable=False)
ragione_sociale = Column(String(255))
codice_fiscale = Column(String(16), unique=True)
partita_iva = Column(String(11), unique=True)
email = Column(String(255), nullable=False)
telefono = Column(String(20))
indirizzo = Column(String(500))
attivo = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relazioni
siti = relationship("Sito", back_populates="cliente", cascade="all, delete-orphan")
utenti = relationship("Utente", back_populates="cliente", cascade="all, delete-orphan")

49
app/models/sito.py Normal file
View File

@@ -0,0 +1,49 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.core.database import Base
class TipoSito(str, enum.Enum):
"""Tipi di siti monitorati"""
FRANA = "frana"
GALLERIA = "galleria"
PONTE = "ponte"
DIGA = "diga"
VERSANTE = "versante"
EDIFICIO = "edificio"
ALTRO = "altro"
class Sito(Base):
"""Modello per i siti monitorati"""
__tablename__ = "siti"
id = Column(Integer, primary_key=True, index=True)
cliente_id = Column(Integer, ForeignKey("clienti.id"), nullable=False)
nome = Column(String(255), nullable=False)
tipo = Column(SQLEnum(TipoSito), nullable=False)
descrizione = Column(String(1000))
# Coordinate geografiche
latitudine = Column(Float)
longitudine = Column(Float)
altitudine = Column(Float)
# Indirizzo
indirizzo = Column(String(500))
comune = Column(String(100))
provincia = Column(String(2))
regione = Column(String(100))
# Metadata
codice_identificativo = Column(String(50), unique=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relazioni
cliente = relationship("Cliente", back_populates="siti")
allarmi = relationship("Allarme", back_populates="sito", cascade="all, delete-orphan")

43
app/models/utente.py Normal file
View File

@@ -0,0 +1,43 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.core.database import Base
class RuoloUtente(str, enum.Enum):
"""Ruoli utente nell'applicazione"""
ADMIN = "admin"
OPERATORE = "operatore"
VISUALIZZATORE = "visualizzatore"
class Utente(Base):
"""Modello per gli utenti dell'applicazione"""
__tablename__ = "utenti"
id = Column(Integer, primary_key=True, index=True)
cliente_id = Column(Integer, ForeignKey("clienti.id"), nullable=False)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
nome = Column(String(100), nullable=False)
cognome = Column(String(100), nullable=False)
telefono = Column(String(20))
ruolo = Column(SQLEnum(RuoloUtente), default=RuoloUtente.VISUALIZZATORE)
# FCM token per notifiche push
fcm_token = Column(String(500))
# Flags
attivo = Column(Boolean, default=True)
email_verificata = Column(Boolean, default=False)
# Metadata
ultimo_accesso = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relazioni
cliente = relationship("Cliente", back_populates="utenti")

0
app/mqtt/__init__.py Normal file
View File

139
app/mqtt/client.py Normal file
View File

@@ -0,0 +1,139 @@
import json
import logging
from typing import Callable, Optional
import paho.mqtt.client as mqtt
from app.core.config import settings
logger = logging.getLogger(__name__)
class MQTTClient:
"""Client MQTT per ricevere allarmi dal sistema di monitoraggio"""
def __init__(self, message_handler: Optional[Callable] = None):
self.client = mqtt.Client(client_id="terrain_monitor_backend")
self.message_handler = message_handler
self._setup_callbacks()
def _setup_callbacks(self):
"""Configura i callback MQTT"""
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
def _on_connect(self, client, userdata, flags, rc):
"""Callback chiamato quando il client si connette al broker"""
if rc == 0:
logger.info("Connesso al broker MQTT")
# Sottoscrizione ai topic (pattern: terrain/{cliente_id}/{sito_id}/alarms)
client.subscribe(settings.MQTT_TOPIC_ALARMS, qos=1)
logger.info(f"Sottoscritto al topic allarmi: {settings.MQTT_TOPIC_ALARMS}")
# Opzionale: sottoscrivi anche telemetry e status se necessario
# client.subscribe(settings.MQTT_TOPIC_TELEMETRY, qos=0)
# client.subscribe(settings.MQTT_TOPIC_STATUS, qos=0)
else:
logger.error(f"Connessione fallita con codice: {rc}")
def _on_disconnect(self, client, userdata, rc):
"""Callback chiamato quando il client si disconnette"""
if rc != 0:
logger.warning(f"Disconnessione inaspettata. Codice: {rc}")
else:
logger.info("Disconnesso dal broker MQTT")
def _on_message(self, client, userdata, msg):
"""
Callback chiamato quando arriva un messaggio
Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
Formato messaggio atteso (JSON):
{
"tipo": "movimento_terreno",
"severita": "critical",
"titolo": "Movimento terreno rilevato",
"descrizione": "Rilevato movimento anomalo...",
"valore_rilevato": 12.5,
"valore_soglia": 10.0,
"unita_misura": "mm",
"timestamp": "2025-10-18T10:30:00Z",
"dati_sensori": {
"sensore_1": 12.5,
"sensore_2": 8.3
}
}
Nota: sito_id e cliente_id vengono estratti dal topic, non dal payload
"""
try:
logger.info(f"Messaggio ricevuto sul topic {msg.topic}")
# Parse topic per estrarre cliente_id e sito_id
# Formato: terrain/{cliente_id}/{sito_id}/alarms
topic_parts = msg.topic.split('/')
if len(topic_parts) >= 4:
cliente_id_str = topic_parts[1]
sito_id_str = topic_parts[2]
# Decodifica e parse payload
payload = json.loads(msg.payload.decode())
# Aggiungi informazioni dal topic al payload
payload['sito_id'] = int(sito_id_str)
payload['cliente_id'] = int(cliente_id_str)
logger.debug(f"Cliente: {cliente_id_str}, Sito: {sito_id_str}, Payload: {payload}")
if self.message_handler:
self.message_handler(payload)
else:
logger.warning("Nessun handler configurato per processare il messaggio")
else:
logger.error(f"Topic malformato: {msg.topic}. Atteso: terrain/{{cliente_id}}/{{sito_id}}/alarms")
except json.JSONDecodeError:
logger.error(f"Errore nel parsing del JSON: {msg.payload}")
except ValueError as e:
logger.error(f"Errore nella conversione di cliente_id o sito_id: {e}")
except Exception as e:
logger.error(f"Errore nel processamento del messaggio: {e}")
def connect(self):
"""Connette al broker MQTT"""
try:
if settings.MQTT_USERNAME and settings.MQTT_PASSWORD:
self.client.username_pw_set(
settings.MQTT_USERNAME,
settings.MQTT_PASSWORD
)
self.client.connect(
settings.MQTT_BROKER_HOST,
settings.MQTT_BROKER_PORT,
keepalive=60
)
logger.info(
f"Connessione a {settings.MQTT_BROKER_HOST}:"
f"{settings.MQTT_BROKER_PORT}"
)
except Exception as e:
logger.error(f"Errore nella connessione al broker MQTT: {e}")
raise
def start(self):
"""Avvia il loop MQTT in background"""
self.connect()
self.client.loop_start()
logger.info("Loop MQTT avviato")
def stop(self):
"""Ferma il loop MQTT"""
self.client.loop_stop()
self.client.disconnect()
logger.info("Loop MQTT fermato")
def set_message_handler(self, handler: Callable):
"""Imposta l'handler per i messaggi ricevuti"""
self.message_handler = handler

150
app/mqtt/handler.py Normal file
View File

@@ -0,0 +1,150 @@
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models import Allarme, Sito, Utente
from app.services.firebase import firebase_service
logger = logging.getLogger(__name__)
class AlarmHandler:
"""Handler per processare gli allarmi ricevuti via MQTT e inviarli tramite FCM"""
def __init__(self):
self.db: Session = None
def _get_db(self) -> Session:
"""Ottiene una nuova sessione database"""
if self.db is None or not self.db.is_active:
self.db = SessionLocal()
return self.db
def handle_alarm(self, payload: dict):
"""
Processa un allarme ricevuto via MQTT
Steps:
1. Valida il payload
2. Salva l'allarme nel database
3. Recupera gli utenti del cliente associato al sito
4. Invia notifiche push tramite FCM
Args:
payload: Dizionario con i dati dell'allarme
"""
db = self._get_db()
try:
# 1. Validazione
sito_id = payload.get("sito_id")
if not sito_id:
logger.error("Payload senza sito_id")
return
# Verifica che il sito esista
sito = db.query(Sito).filter(Sito.id == sito_id).first()
if not sito:
logger.error(f"Sito {sito_id} non trovato")
return
# 2. Salva allarme nel database
timestamp_str = payload.get("timestamp")
timestamp = datetime.fromisoformat(
timestamp_str.replace("Z", "+00:00")
) if timestamp_str else datetime.now(timezone.utc)
allarme = Allarme(
sito_id=sito_id,
tipo=payload.get("tipo"),
severita=payload.get("severita"),
titolo=payload.get("titolo"),
descrizione=payload.get("descrizione"),
messaggio=payload.get("messaggio"),
valore_rilevato=payload.get("valore_rilevato"),
valore_soglia=payload.get("valore_soglia"),
unita_misura=payload.get("unita_misura"),
dati_sensori=payload.get("dati_sensori"),
timestamp_rilevamento=timestamp,
timestamp_notifica=datetime.now(timezone.utc),
)
db.add(allarme)
db.commit()
db.refresh(allarme)
logger.info(f"Allarme {allarme.id} salvato per sito {sito.nome}")
# 3. Recupera utenti del cliente
utenti = (
db.query(Utente)
.filter(
Utente.cliente_id == sito.cliente_id,
Utente.attivo == True,
Utente.fcm_token.isnot(None),
)
.all()
)
if not utenti:
logger.warning(
f"Nessun utente attivo con FCM token per cliente {sito.cliente_id}"
)
return
# 4. Invia notifiche push
self._send_notifications(allarme, sito, utenti)
except Exception as e:
logger.error(f"Errore nel processamento dell'allarme: {e}")
db.rollback()
finally:
db.close()
def _send_notifications(self, allarme: Allarme, sito: Sito, utenti: list[Utente]):
"""Invia notifiche push agli utenti"""
# Determina priorità basata sulla severità
priority = "high" if allarme.severita == "critical" else "normal"
# Prepara dati per la notifica
notification_data = {
"alarm_id": str(allarme.id),
"sito_id": str(sito.id),
"sito_nome": sito.nome,
"tipo": allarme.tipo,
"severita": allarme.severita,
"timestamp": allarme.timestamp_rilevamento.isoformat(),
}
# Prepara titolo e messaggio
title = f"{allarme.severita.upper()}: {sito.nome}"
body = allarme.titolo or allarme.descrizione or "Nuovo allarme rilevato"
# Raccogli tutti i token
tokens = [u.fcm_token for u in utenti if u.fcm_token]
if not tokens:
logger.warning("Nessun token FCM valido trovato")
return
logger.info(f"Invio notifica a {len(tokens)} dispositivi")
# Invia notifica multicast
result = firebase_service.send_multicast(
tokens=tokens,
title=title,
body=body,
data=notification_data,
priority=priority,
)
logger.info(
f"Notifiche inviate per allarme {allarme.id}: "
f"{result['success']} successi, {result['failure']} fallimenti"
)
# Singleton instance
alarm_handler = AlarmHandler()

110
app/mqtt/topics.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Helper per costruire topic MQTT con la struttura standardizzata
Topic structure:
- terrain/{cliente_id}/{sito_id}/alarms - Allarmi critici/warning/info
- terrain/{cliente_id}/{sito_id}/telemetry - Dati sensori periodici
- terrain/{cliente_id}/{sito_id}/status - Stato gateway/sensori
"""
from typing import Literal
TopicType = Literal["alarms", "telemetry", "status"]
class MQTTTopics:
"""Helper per costruire topic MQTT standardizzati"""
BASE = "terrain"
@staticmethod
def build_topic(
cliente_id: int,
sito_id: int,
topic_type: TopicType = "alarms"
) -> str:
"""
Costruisce un topic MQTT standardizzato
Args:
cliente_id: ID del cliente
sito_id: ID del sito
topic_type: Tipo di topic (alarms, telemetry, status)
Returns:
Topic MQTT formattato
Examples:
>>> MQTTTopics.build_topic(5, 17, "alarms")
'terrain/5/17/alarms'
>>> MQTTTopics.build_topic(5, 17, "telemetry")
'terrain/5/17/telemetry'
"""
return f"{MQTTTopics.BASE}/{cliente_id}/{sito_id}/{topic_type}"
@staticmethod
def parse_topic(topic: str) -> dict:
"""
Parse un topic MQTT ed estrae le informazioni
Args:
topic: Topic MQTT da parsare
Returns:
Dict con cliente_id, sito_id, topic_type
Raises:
ValueError: Se il topic è malformato
Examples:
>>> MQTTTopics.parse_topic("terrain/5/17/alarms")
{'cliente_id': 5, 'sito_id': 17, 'topic_type': 'alarms'}
"""
parts = topic.split('/')
if len(parts) < 4:
raise ValueError(
f"Topic malformato: {topic}. "
f"Atteso: terrain/{{cliente_id}}/{{sito_id}}/{{type}}"
)
if parts[0] != MQTTTopics.BASE:
raise ValueError(
f"Topic base errato: {parts[0]}. "
f"Atteso: {MQTTTopics.BASE}"
)
try:
return {
'cliente_id': int(parts[1]),
'sito_id': int(parts[2]),
'topic_type': parts[3]
}
except (ValueError, IndexError) as e:
raise ValueError(
f"Errore nel parsing del topic {topic}: {e}"
)
@staticmethod
def get_subscription_pattern(topic_type: TopicType | None = None) -> str:
"""
Ottiene il pattern di sottoscrizione MQTT
Args:
topic_type: Se specificato, sottoscrive solo a quel tipo
Returns:
Pattern MQTT con wildcards
Examples:
>>> MQTTTopics.get_subscription_pattern()
'terrain/+/+/#'
>>> MQTTTopics.get_subscription_pattern("alarms")
'terrain/+/+/alarms'
"""
if topic_type:
return f"{MQTTTopics.BASE}/+/+/{topic_type}"
else:
# Sottoscrivi a tutti i tipi
return f"{MQTTTopics.BASE}/+/+/#"

0
app/schemas/__init__.py Normal file
View File

56
app/schemas/allarme.py Normal file
View File

@@ -0,0 +1,56 @@
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
from app.models.allarme import SeveritaAllarme, StatoAllarme, TipoAllarme
class AllarmeBase(BaseModel):
"""Schema base per allarmi"""
tipo: TipoAllarme
severita: SeveritaAllarme
titolo: str = Field(..., max_length=255)
descrizione: Optional[str] = None
messaggio: Optional[str] = None
valore_rilevato: Optional[float] = None
valore_soglia: Optional[float] = None
unita_misura: Optional[str] = Field(None, max_length=20)
dati_sensori: Optional[Dict[str, Any]] = None
class AllarmeCreate(AllarmeBase):
"""Schema per creazione allarme"""
sito_id: int
timestamp_rilevamento: datetime
class AllarmeUpdate(BaseModel):
"""Schema per aggiornamento allarme"""
stato: Optional[StatoAllarme] = None
note: Optional[str] = None
risolto_da: Optional[str] = None
class AllarmeResponse(AllarmeBase):
"""Schema per risposta API con allarme"""
id: int
sito_id: int
stato: StatoAllarme
timestamp_rilevamento: datetime
timestamp_notifica: Optional[datetime] = None
created_at: datetime
updated_at: Optional[datetime] = None
note: Optional[str] = None
risolto_da: Optional[str] = None
timestamp_risoluzione: Optional[datetime] = None
class Config:
from_attributes = True
class AllarmeList(BaseModel):
"""Schema per lista paginata di allarmi"""
total: int
items: list[AllarmeResponse]
page: int
page_size: int

25
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class Token(BaseModel):
"""Schema per token JWT"""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Schema per dati contenuti nel token"""
email: Optional[str] = None
cliente_id: Optional[int] = None
class LoginRequest(BaseModel):
"""Schema per richiesta di login"""
email: EmailStr
password: str
class RegisterFCMToken(BaseModel):
"""Schema per registrazione FCM token"""
fcm_token: str

36
app/schemas/sito.py Normal file
View File

@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.sito import TipoSito
class SitoBase(BaseModel):
"""Schema base per siti"""
nome: str
tipo: TipoSito
descrizione: Optional[str] = None
latitudine: Optional[float] = None
longitudine: Optional[float] = None
altitudine: Optional[float] = None
comune: Optional[str] = None
provincia: Optional[str] = None
regione: Optional[str] = None
codice_identificativo: Optional[str] = None
class SitoResponse(SitoBase):
"""Schema per risposta API con sito"""
id: int
cliente_id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class SitoListResponse(BaseModel):
"""Schema per lista siti"""
total: int
items: list[SitoResponse]

View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel
from typing import List
class StatisticheResponse(BaseModel):
"""Schema per statistiche dashboard"""
totale_allarmi: int
totale_siti: int
allarmi_aperti: int
allarmi_recenti_7gg: int
# Per severità
allarmi_critical: int
allarmi_warning: int
allarmi_info: int
# Per stato
allarmi_nuovo: int
allarmi_in_gestione: int
allarmi_risolto: int
# Siti per tipo
siti_ponte: int
siti_galleria: int
siti_diga: int
siti_frana: int
siti_versante: int
siti_edificio: int
class AllarmePerGiornoItem(BaseModel):
"""Item per grafico allarmi per giorno"""
data: str # ISO format date
count: int
class AllarmiPerGiornoResponse(BaseModel):
"""Schema per allarmi raggruppati per giorno"""
dati: List[AllarmePerGiornoItem]

0
app/services/__init__.py Normal file
View File

176
app/services/firebase.py Normal file
View File

@@ -0,0 +1,176 @@
import logging
from typing import List, Optional
import firebase_admin
from firebase_admin import credentials, messaging
from app.core.config import settings
logger = logging.getLogger(__name__)
class FirebaseService:
"""Servizio per gestire le notifiche push tramite Firebase Cloud Messaging"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(FirebaseService, cls).__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self._initialize_firebase()
self.__class__._initialized = True
def _initialize_firebase(self):
"""Inizializza Firebase Admin SDK"""
try:
cred = credentials.Certificate(settings.FIREBASE_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
logger.info("Firebase Admin SDK inizializzato con successo")
except Exception as e:
logger.error(f"Errore nell'inizializzazione di Firebase: {e}")
raise
def send_notification(
self,
token: str,
title: str,
body: str,
data: Optional[dict] = None,
priority: str = "high"
) -> bool:
"""
Invia una notifica push a un singolo dispositivo
Args:
token: FCM token del dispositivo
title: Titolo della notifica
body: Corpo della notifica
data: Dati aggiuntivi da inviare
priority: Priorità della notifica (high/normal)
Returns:
True se inviata con successo, False altrimenti
"""
try:
message = messaging.Message(
notification=messaging.Notification(
title=title,
body=body,
),
data=data or {},
token=token,
android=messaging.AndroidConfig(
priority=priority,
notification=messaging.AndroidNotification(
sound="default",
priority="max" if priority == "high" else "default",
),
),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(
sound="default",
badge=1,
),
),
),
)
response = messaging.send(message)
logger.info(f"Notifica inviata con successo: {response}")
return True
except messaging.UnregisteredError:
logger.warning(f"Token non registrato o scaduto: {token}")
return False
except Exception as e:
logger.error(f"Errore nell'invio della notifica: {e}")
return False
def send_multicast(
self,
tokens: List[str],
title: str,
body: str,
data: Optional[dict] = None,
priority: str = "high"
) -> dict:
"""
Invia una notifica push a più dispositivi
Args:
tokens: Lista di FCM tokens
title: Titolo della notifica
body: Corpo della notifica
data: Dati aggiuntivi da inviare
priority: Priorità della notifica
Returns:
Dizionario con statistiche di invio
"""
if not tokens:
return {"success": 0, "failure": 0}
try:
# Crea messaggio base
base_message = messaging.Message(
notification=messaging.Notification(
title=title,
body=body,
),
data=data or {},
android=messaging.AndroidConfig(
priority=priority,
notification=messaging.AndroidNotification(
sound="default",
priority="max" if priority == "high" else "default",
),
),
apns=messaging.APNSConfig(
payload=messaging.APNSPayload(
aps=messaging.Aps(
sound="default",
badge=1,
),
),
),
)
# Crea un multicast message
multicast_message = messaging.MulticastMessage(
notification=base_message.notification,
data=base_message.data,
android=base_message.android,
apns=base_message.apns,
tokens=tokens,
)
# Usa send_each_for_multicast invece di send_multicast
response = messaging.send_each_for_multicast(multicast_message)
success_count = response.success_count
failure_count = response.failure_count
logger.info(
f"Notifiche inviate: {success_count} successi, "
f"{failure_count} fallimenti"
)
return {
"success": success_count,
"failure": failure_count,
}
except Exception as e:
logger.error(f"Errore nell'invio multicast: {e}")
import traceback
traceback.print_exc()
return {"success": 0, "failure": len(tokens)}
# Singleton instance
firebase_service = FirebaseService()