app backend prima
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
171
app/api/allarmi.py
Normal file
171
app/api/allarmi.py
Normal 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
130
app/api/auth.py
Normal 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
50
app/api/siti.py
Normal 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
127
app/api/statistiche.py
Normal 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
0
app/core/__init__.py
Normal file
44
app/core/config.py
Normal file
44
app/core/config.py
Normal 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
19
app/core/database.py
Normal 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
45
app/core/firebase.py
Normal 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
46
app/core/security.py
Normal 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
107
app/main.py
Normal 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
16
app/models/__init__.py
Normal 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
78
app/models/allarme.py
Normal 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
27
app/models/cliente.py
Normal 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
49
app/models/sito.py
Normal 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
43
app/models/utente.py
Normal 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
0
app/mqtt/__init__.py
Normal file
139
app/mqtt/client.py
Normal file
139
app/mqtt/client.py
Normal 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
150
app/mqtt/handler.py
Normal 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
110
app/mqtt/topics.py
Normal 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
0
app/schemas/__init__.py
Normal file
56
app/schemas/allarme.py
Normal file
56
app/schemas/allarme.py
Normal 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
25
app/schemas/auth.py
Normal 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
36
app/schemas/sito.py
Normal 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]
|
||||
39
app/schemas/statistiche.py
Normal file
39
app/schemas/statistiche.py
Normal 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
0
app/services/__init__.py
Normal file
176
app/services/firebase.py
Normal file
176
app/services/firebase.py
Normal 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()
|
||||
Reference in New Issue
Block a user