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

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/terrain_monitor
# JWT Configuration
SECRET_KEY=your-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# MQTT Configuration
# Nuova architettura: terrain/{cliente_id}/{sito_id}/{message_type}
# Wildcards: + (single level), # (multi level)
MQTT_BROKER_HOST=localhost
MQTT_BROKER_PORT=1883
MQTT_TOPIC_ALARMS=terrain/+/+/alarms
MQTT_TOPIC_TELEMETRY=terrain/+/+/telemetry
MQTT_TOPIC_STATUS=terrain/+/+/status
MQTT_USERNAME=
MQTT_PASSWORD=
# Firebase Configuration
FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
# Application
DEBUG=True
APP_NAME=Terrain Monitor API

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
env/
venv/
ENV/
# Environment
.env
.env.local
# Firebase
firebase-credentials.json
# Database
*.db
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Alembic
alembic/versions/*.py
!alembic/versions/.gitkeep
# mosquitto docker files
mosquitto/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

420
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,420 @@
# Architettura Sistema Terrain Monitor
## Overview
Sistema distribuito per il monitoraggio di infrastrutture critiche (ponti, dighe, gallerie, frane) con notifiche push real-time su app mobile.
## Diagramma Architettura
```
┌─────────────────────────────────────────────────────────────────────┐
│ SISTEMA CENTRALIZZATO │
│ (Elaborazione dati sensori) │
└────────────────────────────┬────────────────────────────────────────┘
│ Pubblica allarmi
┌─────────────────┐
│ MQTT Broker │
│ (Mosquitto) │
│ Port: 1883 │
└────────┬────────┘
│ Topic: terrain/alarms/#
┌────────────────────────────────────────────────────────────────────┐
│ BACKEND (FastAPI) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ MQTT Client │ │
│ │ - Sottoscrive topic allarmi │ │
│ │ - Valida payload JSON │ │
│ └─────────────────────┬────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Alarm Handler │ │
│ │ 1. Salva allarme in DB │ │
│ │ 2. Recupera utenti cliente │ │
│ │ 3. Filtra per FCM token │ │
│ └─────────────────────┬────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Firebase Service │ │
│ │ - Prepara notifica (titolo, body, data) │ │
│ │ - Determina priorità (critical/warning/info) │ │
│ │ - Invia multicast a tutti i dispositivi │ │
│ └─────────────────────┬────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────┴────────────────────────────────────────┐ │
│ │ REST API │ │
│ │ - /auth/* : Autenticazione, FCM token registration │ │
│ │ - /allarmi/* : Lista, dettagli, aggiornamento allarmi │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ │ - clienti, siti, utenti, allarmi │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│ Firebase Cloud Messaging (FCM)
┌────────────────────────────────────────────────────────────────────┐
│ FIREBASE CLOUD MESSAGING │
│ (Google Infrastructure) │
└─────────────────────┬──────────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ APP ANDROID │ │ APP iOS │
│ │ │ │
│ - Login │ │ - Login │
│ - FCM Token │ │ - FCM Token │
│ - Ricevi notif. │ │ - Ricevi notif. │
│ - Lista allarmi │ │ - Lista allarmi │
│ - Dettagli │ │ - Dettagli │
└──────────────────┘ └──────────────────┘
```
## Componenti Dettagliati
### 1. Sistema Centralizzato di Monitoraggio
**Responsabilità:**
- Raccolta dati da centrali remote
- Elaborazione e analisi dati
- Rilevamento anomalie
- Generazione allarmi
**Output:**
- Messaggi JSON su MQTT quando rileva un allarme
- Topic structure: `terrain/alarms/{sito_id}`
### 2. MQTT Broker (Mosquitto)
**Caratteristiche:**
- Protocollo leggero per IoT
- Publish/Subscribe pattern
- QoS (Quality of Service) configurabile
- Persistenza messaggi
**Configurazione:**
- Port: 1883 (MQTT)
- Port: 9001 (WebSocket - opzionale)
### 3. Backend FastAPI
#### 3.1 MQTT Client
```python
- Connessione persistente al broker
- Auto-reconnect in caso di disconnessione
- Parsing e validazione payload JSON
- Handler per processare messaggi
```
#### 3.2 Alarm Handler
```python
Flusso di processamento:
1. Valida payload (sito_id obbligatorio)
2. Verifica esistenza sito in DB
3. Crea record Allarme
4. Query utenti del cliente con FCM token attivo
5. Delega a Firebase Service per invio
```
#### 3.3 Firebase Service
```python
Funzionalità:
- Singleton per riutilizzo connessione
- send_notification() per singolo dispositivo
- send_multicast() per batch invio
- Gestione errori (token scaduti, etc.)
- Configurazione priorità per piattaforma
```
#### 3.4 REST API
```
Endpoints principali:
Authentication:
POST /auth/token # OAuth2 login
POST /auth/login # JSON login
POST /auth/register-fcm-token # Registra device
GET /auth/me # User info
Allarmi:
GET /allarmi # Lista paginata
GET /allarmi/{id} # Dettaglio
PATCH /allarmi/{id} # Aggiorna stato
GET /allarmi/sito/{sito_id} # Per sito
```
#### 3.5 Database (PostgreSQL)
**Schema:**
```sql
clienti
id (PK)
nome, email, telefono
attivo
siti
id (PK)
cliente_id (FK clienti)
nome, tipo (enum)
lat, lng, altitudine
codice_identificativo
utenti
id (PK)
cliente_id (FK clienti)
email (unique)
password_hash
fcm_token (per notifiche)
ruolo (enum: admin, operatore, viewer)
allarmi
id (PK)
sito_id (FK siti)
tipo (enum: movimento_terreno, etc.)
severita (enum: critical, warning, info)
stato (enum: nuovo, risolto, etc.)
valore_rilevato, valore_soglia
dati_sensori (JSONB)
timestamp_rilevamento
```
**Indici:**
```sql
- utenti.email (unique)
- allarmi.sito_id + timestamp_rilevamento
- allarmi.severita
- allarmi.stato
```
### 4. Firebase Cloud Messaging
**Vantaggi:**
- ✓ Supporto nativo Android + iOS
- ✓ Delivery garantito (anche app chiusa)
- ✓ Priorità configurabile
- ✓ Topic e targeting avanzato
- ✓ Analytics integrato
- ✓ Gratuito fino a volumi molto alti
**Formato Notifica:**
```json
{
"notification": {
"title": "CRITICAL: Ponte Morandi",
"body": "Movimento terreno rilevato: 15.5mm"
},
"data": {
"alarm_id": "456",
"sito_id": "123",
"tipo": "movimento_terreno",
"severita": "critical"
},
"android": {
"priority": "high",
"notification": {
"sound": "default",
"priority": "max"
}
},
"apns": {
"payload": {
"aps": {
"sound": "default",
"badge": 1
}
}
}
}
```
### 5. App Mobile (Flutter/React Native)
**Funzionalità principali:**
1. Login con JWT
2. Registrazione FCM token al login
3. Ricezione notifiche push in background
4. Lista allarmi (filtri, paginazione)
5. Dettaglio allarme con mappa
6. Aggiornamento stato (risolto, in gestione)
7. Visualizzazione dati sensori
**Tecnologie consigliate:**
- **Flutter**: Singola codebase, performance native
- **React Native**: Ecosystem JavaScript
- **Firebase SDK**: Gestione notifiche
- **HTTP Client**: Chiamate API REST
## Flusso Dati End-to-End
### Scenario: Allarme Movimento Terreno
```
1. SENSORE
↓ (dati grezzi via LoRa/4G)
2. CENTRALINA LOCALE
↓ (aggregazione e validazione)
3. SISTEMA CENTRALIZZATO
- Riceve dati ogni 5 minuti
- Elabora: valore_attuale = 15.5mm
- Confronta: soglia = 10.0mm
- TRIGGER: 15.5 > 10.0 → ALLARME!
↓ (pubblica MQTT)
4. MQTT BROKER
- Topic: terrain/alarms/123
- Payload JSON con dettagli
↓ (forward a subscriber)
5. BACKEND MQTT CLIENT
- Riceve messaggio
- Valida JSON
- Chiama alarm_handler
6. ALARM HANDLER
- INSERT INTO allarmi (...)
- SELECT utenti WHERE cliente_id = X AND fcm_token IS NOT NULL
- Trova 3 utenti attivi
7. FIREBASE SERVICE
- Prepara notifica multicast
- Priorità: HIGH (severità = critical)
- Invia a 3 FCM tokens
8. FIREBASE CLOUD MESSAGING
- Delivery a Google/Apple servers
- Push a dispositivi
9. APP MOBILE (3 dispositivi)
- Riceve notifica
- Mostra: "CRITICAL: Ponte Morandi"
- Suono + vibrazione
- Badge icon: 1 nuovo
- Tap → apre dettaglio allarme
10. UTENTE
- Visualizza dati completi
- Vede mappa sito
- Cambia stato → "in_gestione"
- PATCH /allarmi/456 → aggiorna DB
```
**Latenza totale:** < 2 secondi (dal publish MQTT alla notifica)
## Sicurezza
### Autenticazione
- JWT tokens (HS256)
- Password hashing (bcrypt)
- Token expiration configurabile
### Autorizzazione
- Multi-tenant: isolamento per cliente_id
- Ruoli: admin, operatore, visualizzatore
- Filter automatico query per cliente
### Network
- HTTPS obbligatorio (produzione)
- MQTT con TLS + credenziali
- CORS configurato per domini specifici
- Rate limiting (TODO)
### Database
- Prepared statements (SQLAlchemy ORM)
- No SQL injection possibile
- Backup automatici
- Encryption at rest (configurazione DB)
## Scalabilità
### Orizzontale
- Backend stateless → multiple instances
- Load balancer (nginx/HAProxy)
- Database: read replicas per query
### Verticale
- PostgreSQL: ottimizzazioni indici
- MQTT: clustering (EMQX per produzione)
- Firebase: gestisce automaticamente
### Performance Stimata
- 1000 allarmi/minuto: OK con 1 istanza
- 10000+ allarmi/minuto: 3-5 istanze + DB tuning
- FCM: 1M+ notifiche/secondo (gestito da Google)
## Monitoring e Logging
### Logs
- Application: Python logging module
- MQTT: Mosquitto logs
- Database: PostgreSQL logs
- Aggregation: ELK Stack o CloudWatch
### Metriche
- Allarmi ricevuti/inviati
- Latenza MQTT → FCM
- Success rate notifiche
- Database query performance
### Alerting
- Backend down → PagerDuty
- MQTT disconnection → Email
- Database errors → Slack
- High latency → Grafana alerts
## Disaster Recovery
### Backup
- Database: pg_dump giornaliero
- Configurazione: Git repository
- Logs: retention 30 giorni
### Fault Tolerance
- MQTT: auto-reconnect + QoS 1
- Database: replication master-slave
- Backend: health check + auto-restart
- FCM: retry automatico (gestito da SDK)
## Costi Stimati (Produzione)
### Infrastruttura
- Backend: $50-100/mese (2 istanze AWS EC2 t3.medium)
- Database: $100-200/mese (AWS RDS PostgreSQL)
- MQTT: $50-100/mese (EMQX Cloud o self-hosted)
- **Totale:** ~$200-400/mese
### Servizi Esterni
- Firebase FCM: **GRATIS** (fino a milioni di notifiche/giorno)
- Domain + SSL: $20/anno
- Monitoring: $50/mese (Datadog/New Relic)
### Totale Stimato
**$250-500/mese** per sistema completo in produzione
---
## Prossimi Miglioramenti
1. **WebSocket** per notifiche real-time su web dashboard
2. **GraphQL** alternativa a REST API
3. **Redis** per caching e rate limiting
4. **Kubernetes** per orchestrazione container
5. **TimescaleDB** per dati time-series sensori
6. **Machine Learning** per predizione anomalie
7. **Geofencing** per notifiche basate su posizione
8. **Multi-language** support (i18n)

257
MQTT_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,257 @@
# Architettura MQTT - Sistema di Monitoraggio Terreni
## 📡 Struttura Topic
La nuova architettura usa una struttura gerarchica dei topic MQTT per garantire scalabilità e multi-tenancy:
```
terrain/{cliente_id}/{sito_id}/{message_type}
```
### Tipi di Messaggi
| Topic Type | Descrizione | QoS | Frequenza |
|------------|-------------|-----|-----------|
| `alarms` | Allarmi critici/warning/info | 1 | On-event |
| `telemetry` | Dati sensori periodici | 0 | 1-60 min |
| `status` | Stato gateway/sensori | 0 | Periodico |
### Esempi
```bash
# Allarme critico per il sito 17 del cliente 5
terrain/5/17/alarms
# Telemetria sensori
terrain/5/17/telemetry
# Stato del gateway
terrain/5/17/status
```
## 📋 Formato Messaggi
### Allarmi (`/alarms`)
**Topic**: `terrain/{cliente_id}/{sito_id}/alarms`
**Payload** (JSON):
```json
{
"tipo": "movimento_terreno",
"severita": "critical",
"titolo": "Movimento terreno rilevato",
"descrizione": "Rilevato movimento anomalo...",
"valore_rilevato": 25.5,
"valore_soglia": 10.0,
"unita_misura": "mm",
"timestamp": "2025-10-19T10:30:00Z",
"dati_sensori": {
"sensore_1": 25.5,
"sensore_2": 18.3
}
}
```
**Note**:
- `sito_id` e `cliente_id` vengono estratti dal topic (non dal payload)
- `severita`: `critical`, `warning`, `info`
- `tipo`: `movimento_terreno`, `inclinazione`, `vibrazioni`, `cedimento`, `altro`
### Telemetria (`/telemetry`)
**Topic**: `terrain/{cliente_id}/{sito_id}/telemetry`
**Payload** (JSON):
```json
{
"timestamp": "2025-10-19T10:30:00Z",
"sensori": {
"inclinometro_1": {
"valore": 12.5,
"unita": "gradi",
"stato": "ok"
},
"temperatura": {
"valore": 15.5,
"unita": "celsius"
}
},
"batteria": 85,
"segnale": -65
}
```
### Status (`/status`)
**Topic**: `terrain/{cliente_id}/{sito_id}/status`
**Payload** (JSON):
```json
{
"timestamp": "2025-10-19T10:30:00Z",
"gateway_online": true,
"sensori_attivi": 4,
"sensori_totali": 4,
"ultimo_heartbeat": "2025-10-19T10:29:00Z",
"errori": []
}
```
## 🔌 Sottoscrizioni Backend
Il backend si sottoscrive usando wildcards MQTT:
```python
# Sottoscrivi a tutti gli allarmi
client.subscribe("terrain/+/+/alarms", qos=1)
# Sottoscrivi a tutta la telemetria
client.subscribe("terrain/+/+/telemetry", qos=0)
# Sottoscrivi a tutti i messaggi di status
client.subscribe("terrain/+/+/status", qos=0)
```
**Wildcards**:
- `+` : single-level wildcard (un livello)
- `#` : multi-level wildcard (tutti i livelli rimanenti)
## 🔒 Sicurezza
### Development
- Autenticazione: Anonymous (allow_anonymous = true)
- Encryption: Nessuna
### Production (TODO)
```conf
# mosquitto.conf
allow_anonymous false
password_file /mosquitto/config/passwd
# TLS/SSL
listener 8883
cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
```
## 💾 Persistenza
Mosquitto è configurato con persistenza su disco:
```conf
persistence true
persistence_location /mosquitto/data/
autosave_interval 300 # Salva ogni 5 minuti
```
**Vantaggi**:
- Messaggi QoS > 0 non persi in caso di restart
- Sottoscrizioni persistenti
- Retained messages salvati
## 🧪 Test
### 1. Invia allarme di test
```bash
cd /home/alex/devel/web-app-python
source .venv/bin/activate
python scripts/test_mqtt_improved.py
```
### 2. Monitor MQTT (subscriber)
```bash
# Installa mosquitto-clients
sudo apt-get install mosquitto-clients
# Sottoscrivi a tutti gli allarmi
mosquitto_sub -h localhost -t "terrain/+/+/alarms" -v
# Sottoscrivi a tutti i messaggi
mosquitto_sub -h localhost -t "terrain/#" -v
```
### 3. Pubblica messaggio manuale
```bash
# Allarme critico
mosquitto_pub -h localhost \
-t "terrain/5/17/alarms" \
-m '{"tipo":"movimento_terreno","severita":"critical","titolo":"Test","timestamp":"2025-10-19T10:30:00Z"}' \
-q 1
```
## 📊 Helper Python
Usa l'helper `MQTTTopics` per costruire topic standardizzati:
```python
from app.mqtt.topics import MQTTTopics
# Costruisci topic
topic = MQTTTopics.build_topic(
cliente_id=5,
sito_id=17,
topic_type="alarms"
)
# Output: "terrain/5/17/alarms"
# Parse topic
info = MQTTTopics.parse_topic("terrain/5/17/alarms")
# Output: {'cliente_id': 5, 'sito_id': 17, 'topic_type': 'alarms'}
# Pattern sottoscrizione
pattern = MQTTTopics.get_subscription_pattern("alarms")
# Output: "terrain/+/+/alarms"
```
## 🚀 Migrazione dal Vecchio Sistema
### Prima (vecchio)
```
Topic: terrain/alarms/1
Payload: {"sito_id": 17, "tipo": "...", ...}
```
### Dopo (nuovo)
```
Topic: terrain/5/17/alarms
Payload: {"tipo": "...", ...} # sito_id estratto dal topic
```
### Retrocompatibilità
Il sistema continua a funzionare con il vecchio formato, ma è consigliato migrare ai nuovi topic per:
- ✅ Migliore separazione multi-tenant
- ✅ Filtraggio per cliente
- ✅ Scalabilità
- ✅ Standard IoT
## 📈 Performance
- **Max throughput**: ~10,000 msg/sec (con QoS 0)
- **Latenza**: < 10ms (LAN)
- **Retained messages**: 1000 max
- **Connections**: Illimitate (configurabile)
## 🔍 Monitoraggio
### Log Mosquitto
```bash
docker-compose logs -f mosquitto
```
### Log Backend MQTT
```bash
# Filtro solo log MQTT
grep "MQTT" logs/app.log | tail -50
```
### Statistiche Broker
```bash
# Connetti e visualizza statistiche
mosquitto_sub -h localhost -t '$SYS/#' -v
```

239
QUICKSTART.md Normal file
View File

@@ -0,0 +1,239 @@
# Quick Start Guide - Terrain Monitor
Guida rapida per avviare il sistema di monitoraggio terreni in locale.
## Prerequisiti
- Python 3.12+
- Docker e Docker Compose
- Account Firebase (gratuito)
## Setup in 5 Passi
### 1. Clona e Prepara Ambiente
```bash
# Entra nella directory del progetto
cd web-app-python
# Crea virtual environment
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
# Installa dipendenze
pip install -e .
```
### 2. Avvia Infrastruttura (PostgreSQL + MQTT)
```bash
# Avvia database e broker MQTT
docker-compose up -d
# Verifica che siano in esecuzione
docker-compose ps
```
Servizi disponibili:
- PostgreSQL: `localhost:5432`
- MQTT Broker: `localhost:1883`
- pgAdmin: http://localhost:5050 (user: admin@terrain.local, pwd: admin)
### 3. Configura Firebase
1. Vai su https://console.firebase.google.com/
2. Crea nuovo progetto "Terrain Monitor"
3. Abilita Cloud Messaging
4. Scarica credenziali:
- ⚙️ Settings → Service Accounts
- "Generate new private key"
- Salva come `firebase-credentials.json` nella root
### 4. Configura Variabili d'Ambiente
```bash
# Copia template
cp .env.example .env
# Modifica .env (usa questi valori per local development)
nano .env
```
```env
DATABASE_URL=postgresql://terrain_user:terrain_pass@localhost:5432/terrain_monitor
SECRET_KEY=dev-secret-key-change-in-production-minimum-32-chars
MQTT_BROKER_HOST=localhost
MQTT_BROKER_PORT=1883
MQTT_TOPIC=terrain/alarms/#
FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
DEBUG=True
```
### 5. Inizializza Database e Avvia Server
```bash
# Inizializza database con dati di esempio
python scripts/init_db.py
# Avvia il server FastAPI
python main.py
```
Server disponibile su: http://localhost:8000
## Test del Sistema
### 1. Verifica API
Apri http://localhost:8000/docs per la documentazione interattiva Swagger.
### 2. Login
```bash
curl -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@azienda.it",
"password": "admin123"
}'
```
Salva il token restituito.
### 3. Test Allarme MQTT
In un nuovo terminale:
```bash
# Attiva virtual environment
source .venv/bin/activate
# Invia allarme di test
python scripts/test_mqtt.py
```
Dovresti vedere:
1. ✓ Log nel backend che riceve l'allarme
2. ✓ Nuovo record nella tabella `allarmi`
3. ✓ Notifica push inviata (se hai configurato FCM token)
### 4. Verifica Allarmi via API
```bash
curl -X GET http://localhost:8000/allarmi \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Credenziali Utenti Demo
Dopo `init_db.py` avrai questi utenti:
| Email | Password | Ruolo |
|-------|----------|-------|
| admin@azienda.it | admin123 | Admin |
| operatore@azienda.it | operatore123 | Operatore |
| viewer@azienda.it | viewer123 | Visualizzatore |
## Siti Monitorati Demo
1. **Ponte Morandi** (ID: 1) - Genova
2. **Galleria San Boldo** (ID: 2) - Treviso
3. **Diga del Vajont** (ID: 3) - Pordenone
4. **Versante Cà di Sotto** (ID: 4) - Parma
## Struttura Allarme MQTT
Per inviare allarmi personalizzati via MQTT:
```bash
mosquitto_pub -h localhost -t "terrain/alarms/1" -m '{
"sito_id": 1,
"tipo": "movimento_terreno",
"severita": "critical",
"titolo": "Movimento rilevato",
"descrizione": "Movimento anomalo di 15mm",
"valore_rilevato": 15.0,
"valore_soglia": 10.0,
"unita_misura": "mm",
"timestamp": "2025-10-18T10:30:00Z",
"dati_sensori": {
"sensore_1": 15.0,
"sensore_2": 12.3
}
}'
```
## Monitoraggio Logs
```bash
# Backend logs
# Visibili nel terminale dove hai eseguito python main.py
# MQTT broker logs
docker-compose logs -f mosquitto
# Database logs
docker-compose logs -f postgres
```
## Stop e Cleanup
```bash
# Ferma il server FastAPI
Ctrl+C nel terminale
# Ferma i container Docker
docker-compose down
# Rimuovi anche i dati (ATTENZIONE: cancella il database!)
docker-compose down -v
```
## Troubleshooting
### Errore: "Connection refused" su PostgreSQL
```bash
# Verifica che il container sia in esecuzione
docker-compose ps
# Riavvia PostgreSQL
docker-compose restart postgres
```
### Errore: "Firebase credentials not found"
```bash
# Verifica che il file esista
ls -la firebase-credentials.json
# Controlla il path in .env
cat .env | grep FIREBASE
```
### MQTT non riceve messaggi
```bash
# Testa la connessione MQTT
mosquitto_sub -h localhost -t "terrain/alarms/#" -v
# In un altro terminale, pubblica un test
mosquitto_pub -h localhost -t "terrain/alarms/test" -m "hello"
```
## Prossimi Passi
1. **App Mobile**: Sviluppa client Flutter/React Native
2. **Integrazione**: Connetti il tuo sistema di monitoraggio reale
3. **Deployment**: Usa Docker per deployare in produzione
4. **Sicurezza**: Configura MQTT con autenticazione
5. **Monitoring**: Aggiungi logging e metriche
## Risorse
- FastAPI Docs: https://fastapi.tiangolo.com/
- Firebase FCM: https://firebase.google.com/docs/cloud-messaging
- MQTT Protocol: https://mqtt.org/
- SQLAlchemy: https://docs.sqlalchemy.org/
## Supporto
Per problemi o domande, consulta il [README.md](README.md) completo.

BIN
README.md Normal file

Binary file not shown.

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()

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
version: '3.8'
services:
# Database PostgreSQL
postgres:
image: postgres:16-alpine
container_name: terrain_monitor_db
environment:
POSTGRES_USER: terrain_user
POSTGRES_PASSWORD: terrain_pass
POSTGRES_DB: terrain_monitor
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U terrain_user"]
interval: 10s
timeout: 5s
retries: 5
# MQTT Broker (Mosquitto)
mosquitto:
image: eclipse-mosquitto:2
container_name: terrain_monitor_mqtt
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
command: mosquitto -c /mosquitto/config/mosquitto.conf
# pgAdmin (opzionale - UI per PostgreSQL)
pgadmin:
image: dpage/pgadmin4:latest
container_name: terrain_monitor_pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@aseltd.eu
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
- postgres
volumes:
postgres_data:

20
main.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Entry point per l'applicazione Terrain Monitor
Per avviare il server:
python main.py
Oppure con uvicorn:
uvicorn app.main:app --reload
"""
if __name__ == "__main__":
import uvicorn
from app.core.config import settings
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
)

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "terrain-monitor-backend"
version = "0.1.0"
description = "Sistema di monitoraggio terreni con notifiche push per allarmi real-time"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"sqlalchemy>=2.0.35",
"alembic>=1.13.3",
"psycopg2-binary>=2.9.10",
"pydantic[email]>=2.9.2",
"pydantic-settings>=2.6.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"bcrypt>=4.0.0,<5.0.0",
"python-multipart>=0.0.12",
"paho-mqtt>=2.1.0",
"firebase-admin>=6.5.0",
"python-dotenv>=1.0.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.3",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.2",
]

18
run_with_cloud_env.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Script per eseguire comandi Python con configurazione cloud
export DATABASE_URL="postgresql://terrain_user:terrain_pass@94.177.199.207:5432/terrain_monitor"
export MQTT_BROKER_HOST="94.177.199.207"
export MQTT_BROKER_PORT=1883
export SECRET_KEY="batt1l0ngs3cur3k3y"
export MQTT_TOPIC_ALARMS="terrain/+/+/alarms"
export MQTT_TOPIC_TELEMETRY="terrain/+/+/telemetry"
export MQTT_TOPIC_STATUS="terrain/+/+/status"
export DEBUG=True
export FIREBASE_CREDENTIALS_PATH="./firebase-credentials.json"
# Attiva virtual environment
source .venv/bin/activate
# Esegui il comando passato come argomento
"$@"

191
scripts/init_db.py Normal file
View File

@@ -0,0 +1,191 @@
"""
Script per inizializzare il database con dati di esempio
"""
import sys
import os
# 1. Ottieni il percorso assoluto della directory corrente dello script
current_dir = os.path.dirname(os.path.abspath(__file__))
# 2. Risali di un livello per raggiungere la directory radice (quella che contiene 'app' e 'scripts')
# Esempio: da /percorso/al/progetto/scripts a /percorso/al/progetto
project_root = os.path.join(current_dir, '..')
# 3. Aggiungi la directory radice al path di ricerca dei moduli di Python
sys.path.append(project_root)
# 4. Carica variabili d'ambiente dal file .env
from dotenv import load_dotenv
env_path = os.path.join(project_root, '.env')
load_dotenv(env_path)
from datetime import datetime, timezone
from app.core.database import SessionLocal, engine, Base
from app.models import Cliente, Sito, Utente, TipoSito, RuoloUtente
from app.core.security import get_password_hash
def init_database():
"""Crea le tabelle e inserisce dati di esempio"""
# Crea tutte le tabelle
print("Creazione tabelle...")
Base.metadata.create_all(bind=engine)
print("✓ Tabelle create")
db = SessionLocal()
try:
# Verifica se esistono già dati
existing_cliente = db.query(Cliente).first()
if existing_cliente:
print("⚠ Database già inizializzato. Skip.")
return
# 1. Crea cliente di esempio
print("\nCreazione cliente di esempio...")
cliente = Cliente(
nome="Azienda Monitoraggio SRL",
ragione_sociale="Azienda Monitoraggio SRL",
partita_iva="12345678901",
email="info@aziendamonitoraggio.it",
telefono="+39 02 1234567",
indirizzo="Via Roma 1, 20100 Milano",
attivo=True,
)
db.add(cliente)
db.flush()
print(f"✓ Cliente creato: {cliente.nome} (ID: {cliente.id})")
# 2. Crea siti di esempio
print("\nCreazione siti monitorati...")
siti = [
Sito(
cliente_id=cliente.id,
nome="Ponte Morandi",
tipo=TipoSito.PONTE,
descrizione="Ponte autostradale sulla A10",
latitudine=44.4268,
longitudine=8.9137,
comune="Genova",
provincia="GE",
regione="Liguria",
codice_identificativo="PON-001",
),
Sito(
cliente_id=cliente.id,
nome="Galleria San Boldo",
tipo=TipoSito.GALLERIA,
descrizione="Galleria stradale sul passo San Boldo",
latitudine=45.9811,
longitudine=12.1064,
comune="Cison di Valmarino",
provincia="TV",
regione="Veneto",
codice_identificativo="GAL-002",
),
Sito(
cliente_id=cliente.id,
nome="Diga del Vajont",
tipo=TipoSito.DIGA,
descrizione="Diga sul torrente Vajont",
latitudine=46.2691,
longitudine=12.3281,
comune="Erto e Casso",
provincia="PN",
regione="Friuli-Venezia Giulia",
codice_identificativo="DIG-003",
),
Sito(
cliente_id=cliente.id,
nome="Versante Cà di Sotto",
tipo=TipoSito.FRANA,
descrizione="Area soggetta a movimento franoso",
latitudine=44.3521,
longitudine=10.8464,
comune="Corniglio",
provincia="PR",
regione="Emilia-Romagna",
codice_identificativo="FRA-004",
),
]
for sito in siti:
db.add(sito)
print(f"✓ Sito creato: {sito.nome} ({sito.tipo})")
db.flush()
# 3. Crea utenti di esempio
print("\nCreazione utenti...")
utenti = [
Utente(
cliente_id=cliente.id,
email="admin@azienda.it",
password_hash=get_password_hash("admin123"),
nome="Mario",
cognome="Rossi",
telefono="+39 333 1234567",
ruolo=RuoloUtente.ADMIN,
attivo=True,
email_verificata=True,
),
Utente(
cliente_id=cliente.id,
email="operatore@azienda.it",
password_hash=get_password_hash("operatore123"),
nome="Luca",
cognome="Bianchi",
telefono="+39 333 7654321",
ruolo=RuoloUtente.OPERATORE,
attivo=True,
email_verificata=True,
),
Utente(
cliente_id=cliente.id,
email="viewer@azienda.it",
password_hash=get_password_hash("viewer123"),
nome="Anna",
cognome="Verdi",
telefono="+39 333 9876543",
ruolo=RuoloUtente.VISUALIZZATORE,
attivo=True,
email_verificata=True,
),
]
for utente in utenti:
db.add(utente)
print(f"✓ Utente creato: {utente.email} ({utente.ruolo})")
# Commit finale
db.commit()
print("\n" + "=" * 60)
print("✓ Database inizializzato con successo!")
print("=" * 60)
print("\nCredenziali di accesso:")
print("-" * 60)
for utente in utenti:
pwd = "admin123" if utente.ruolo == RuoloUtente.ADMIN else \
"operatore123" if utente.ruolo == RuoloUtente.OPERATORE else "viewer123"
print(f"Email: {utente.email:25} | Password: {pwd:15} | Ruolo: {utente.ruolo}")
print("-" * 60)
print(f"\nSiti monitorati creati: {len(siti)}")
for i, sito in enumerate(siti, 1):
print(f" {i}. {sito.nome} (ID: {sito.id}) - {sito.tipo}")
print()
except Exception as e:
print(f"\n✗ Errore durante l'inizializzazione: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
print("=" * 60)
print("INIZIALIZZAZIONE DATABASE - Terrain Monitor")
print("=" * 60)
init_database()

View File

@@ -0,0 +1,121 @@
"""
Script per testare l'invio di un allarme via MQTT con nuova struttura topic
Topic pattern: terrain/{cliente_id}/{sito_id}/alarms
Questo script usa la nuova architettura MQTT migliorata dove:
- cliente_id e sito_id sono nel topic (non nel payload)
- Supporto per diversi tipi di messaggi (alarms, telemetry, status)
- QoS 1 per garantire delivery
"""
import json
import time
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
# Configurazione (modifica secondo il tuo setup)
MQTT_BROKER = "94.177.199.207"
MQTT_PORT = 1883
# ID dal database (verifica con: SELECT id, cliente_id, nome FROM siti;)
CLIENTE_ID = 1 # Cliente ID del sito
SITO_ID = 1 # ID del sito "Ponte Morandi"
# Topic con nuova struttura: terrain/{cliente_id}/{sito_id}/alarms
MQTT_TOPIC = f"terrain/{CLIENTE_ID}/{SITO_ID}/alarms"
# Allarme di test (sito_id e cliente_id vengono estratti dal topic)
alarm_data = {
"tipo": "movimento_terreno",
"severita": "critical", # critical, warning, info
"titolo": "🚨 TEST: Movimento terreno critico",
"descrizione": "Allarme di test per verificare notifiche push - Sistema funzionante!",
"messaggio": "Rilevato movimento anomalo superiore alla soglia di sicurezza",
"valore_rilevato": 25.5,
"valore_soglia": 10.0,
"unita_misura": "mm",
"timestamp": datetime.now(timezone.utc).isoformat(),
"dati_sensori": {
"sensore_inclinometro_1": 25.5,
"sensore_inclinometro_2": 18.3,
"temperatura": 15.5,
"umidita": 45.0
}
}
def on_connect(client, userdata, flags, rc):
"""Callback quando il client si connette"""
if rc == 0:
print("✓ Connesso al broker MQTT")
else:
print(f"✗ Connessione fallita con codice: {rc}")
def on_publish(client, userdata, mid):
"""Callback quando il messaggio viene pubblicato"""
print(f"✓ Messaggio pubblicato (message_id: {mid})")
def send_test_alarm():
"""Invia un allarme di test"""
print("=" * 70)
print("TEST INVIO ALLARME VIA MQTT - NUOVA ARCHITETTURA")
print("=" * 70)
# Crea client MQTT
client = mqtt.Client(client_id="test_alarm_sender_v2")
client.on_connect = on_connect
client.on_publish = on_publish
try:
# Connetti al broker
print(f"\nConnessione a {MQTT_BROKER}:{MQTT_PORT}...")
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
# Start loop
client.loop_start()
time.sleep(1) # Aspetta la connessione
# Pubblica l'allarme
print(f"\nInvio allarme:")
print(f" Topic: {MQTT_TOPIC}")
print(f" Cliente ID: {CLIENTE_ID}")
print(f" Sito ID: {SITO_ID}")
print("\nPayload:")
print(json.dumps(alarm_data, indent=2, ensure_ascii=False))
print()
result = client.publish(
MQTT_TOPIC,
json.dumps(alarm_data),
qos=1 # QoS 1 per garantire delivery
)
# Aspetta che il messaggio sia pubblicato
result.wait_for_publish()
print("\n✓ Allarme inviato con successo!")
print("\nControlla:")
print(" 1. I log del backend per vedere se è stato ricevuto")
print(" 2. Il database per verificare che l'allarme sia stato salvato")
print(" 3. L'app mobile per verificare la ricezione della notifica push")
print("\nComandi utili:")
print(" - Logs backend: tail -f logs/app.log")
print(" - DB query: SELECT * FROM allarmi ORDER BY created_at DESC LIMIT 5;")
# Cleanup
time.sleep(1)
client.loop_stop()
client.disconnect()
except Exception as e:
print(f"\n✗ Errore: {e}")
client.loop_stop()
raise
print("\n" + "=" * 70)
if __name__ == "__main__":
send_test_alarm()

1528
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff