Centraline Ambientali - Bagnoli

POC (Proof of Concept) per il monitoraggio ambientale dell'area di Bagnoli (NA). Questa documentazione serve come brogliaccio per gli sviluppatori frontend e backend che implementeranno il sistema a microservizi.

Scopo di questo documento: fornire ai progettisti tutte le specifiche per replicare questa POC usando Django REST Framework (backend) + Angular 19 (frontend), con architettura a microservizi.

Architettura Target

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        FRONTEND                              β”‚
β”‚                   Angular 19 + SSR                           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
β”‚  β”‚Dashboard β”‚ β”‚  Meteo   β”‚ β”‚Inquinantiβ”‚ β”‚  Admin   β”‚       β”‚
β”‚  β”‚Component β”‚ β”‚Component β”‚ β”‚Component β”‚ β”‚Component β”‚       β”‚
β”‚  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜       β”‚
β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
β”‚                         β”‚ HttpClient                         β”‚
β”‚                         β”‚ + AuthInterceptor                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ REST API (JSON)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    API GATEWAY                               β”‚
β”‚                   Django REST Framework                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
β”‚  β”‚ Auth     β”‚ β”‚Centralineβ”‚ β”‚  Dati    β”‚ β”‚  Export  β”‚       β”‚
β”‚  β”‚ Service  β”‚ β”‚ Service  β”‚ β”‚ Service  β”‚ β”‚ Service  β”‚       β”‚
β”‚  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜       β”‚
β”‚       β”‚             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
β”‚       β”‚                         β”‚                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚  β”‚ SQLite   β”‚          β”‚  PostgreSQL    β”‚                   β”‚
β”‚  β”‚ (users)  β”‚          β”‚  (read-only)   β”‚                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Stack Tecnologico

LayerPOC (attuale)Target (produzione)
FrontendHTML + Vanilla JS + Chart.js + LeafletAngular 19 + ng2-charts + ngx-leaflet
APIPHP vanilla (api/index.php)Django REST Framework 3.15+
AuthJWT custom (PHP)djangorestframework-simplejwt
DB DatiPostgreSQL (schema bagnoli_ambiente_dev) - read only
DB AuthSQLite localeSQLite o PostgreSQL dedicato
DeployGit push + webhookDocker Compose (microservizi)

Autenticazione

L'autenticazione usa JWT (JSON Web Token). Il flusso e':

  1. POST /api/?action=login con username/password
  2. Ricevi token JWT
  3. Includi Authorization: Bearer {token} in ogni richiesta protetta
  4. Token scade dopo 24h

Credenziali default: admin / admin

POST /api/?action=login PUBLIC

Request Body

{
  "username": "admin",
  "password": "admin"
}

Response 200

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {
    "id": 1,
    "username": "admin",
    "role": "admin"
  }
}

Equivalente Django

# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView
urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain'),
]

API - Centraline

GET /api/?action=centraline PUBLIC

Lista tutte le centraline con la localizzazione attiva.

Response

{
  "data": [
    {
      "id": 1,
      "name": "MMB 274 - Bagnoli - Interno SIN",
      "alias": "1793",
      "codice": "1793",
      "tipo": "ISPRA",
      "status": 1,
      "colore_mappa": "#16a34a",
      "is_manuale": false,
      "lat": "40.8127060",
      "lng": "14.1677380",
      "nome_comune": "Bagnoli",
      "nome_provincia": "NA"
    }
  ]
}

Equivalente Django

# views.py
class CentralinaViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Centralina.objects.select_related('localizzazione_attiva').all()
    serializer_class = CentralinaSerializer

# serializers.py
class CentralinaSerializer(serializers.ModelSerializer):
    lat = serializers.DecimalField(source='localizzazione_attiva.lat')
    lng = serializers.DecimalField(source='localizzazione_attiva.lng')
    nome_comune = serializers.CharField(source='localizzazione_attiva.nome_comune')

    class Meta:
        model = Centralina
        fields = '__all__'
GET /api/?action=centralina&id={id} PUBLIC

Dettaglio singola centralina.

ParametroTipoObbligatorioDescrizione
idintegerSiID centralina

API - Dati Ambientali

GET /api/?action=dati&id={id}&from={date}&to={date} PUBLIC

Dati aggregati da tutte le fonti (tabella dati_all). I valori sono separati da ;.

ParametroTipoDefaultDescrizione
idintegernull (tutte)Filtra per centralina
fromdate-7 giorniData inizio (YYYY-MM-DD)
todateoggiData fine
limitinteger500 (max 2000)Numero max record

Equivalente Django

# views.py
class DatiAllViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = DatiAllSerializer
    filterset_fields = ['id_centralina', 'fonte']

    def get_queryset(self):
        qs = DatiAll.objects.all()
        from_date = self.request.query_params.get('from')
        to_date = self.request.query_params.get('to')
        if from_date:
            qs = qs.filter(data_ora__gte=from_date)
        if to_date:
            qs = qs.filter(data_ora__lte=to_date + ' 23:59:59')
        return qs.order_by('-data_ora')

API - Dati Meteo

GET /api/?action=meteo&from={date}&to={date} PUBLIC

Dati meteorologici dalla tabella meteostatnet_dati.

Campi response

CampoTipoUnitaDescrizione
tempnumeric°CTemperatura
dwptnumeric°CPunto di rugiada
rhumnumeric%Umidita relativa
prcpnumericmmPrecipitazioni
snownumericmmNeve
wdirnumeric°Direzione vento
wspdnumerickm/hVelocita vento
wpgtnumerickm/hRaffica max
presnumerichPaPressione atmosferica
tsunnumericminDurata sole
cocointeger-Codice condizione meteo

API - Dati ISPRA (Inquinanti)

GET /api/?action=ispra&id={id}&from={date}&to={date} PUBLIC

Dati inquinanti dalle centraline ISPRA (tabelle opas_ispra_1793 e opas_ispra_1798).

Campi response

CampoTipoUnitaDescrizione
so2numericµg/m³Biossido di zolfo
o3numericµg/m³Ozono
no2numericµg/m³Biossido di azoto
conumericmg/m³Monossido di carbonio
pm2_5_fidasnumericµg/m³Particolato fine PM2.5
pm10_fidasnumericµg/m³Particolato PM10
benzenenumericµg/m³Benzene

Soglie di legge (riferimento)

InquinanteLimite orarioLimite giornalieroLimite annuale
NO&sub2;200 µg/m³-40 µg/m³
PM10-50 µg/m³40 µg/m³
PM2.5--25 µg/m³
O&sub3;180 µg/m³ (info)--
SO&sub2;350 µg/m³125 µg/m³-
CO-10 mg/m³ (8h)-
Benzene--5 µg/m³

API - Statistiche

GET /api/?action=stats&id={id}&days={n} PUBLIC

Statistiche aggregate (medie, min, max) per il periodo richiesto.

ParametroDefaultDescrizione
idnullFiltra centralina ISPRA
days30 (max 365)Giorni indietro

API - Export (Autenticato)

GET /api/?action=export&id={id}&from=&to=&format=csv AUTH REQUIRED

Esporta dati in JSON o CSV. Richiede token JWT.

ParametroValoriDescrizione
formatjson, csvFormato export

Backend Django - Struttura Progetto

centraline-backend/
β”œβ”€β”€ manage.py
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ settings/
β”‚   β”‚   β”œβ”€β”€ base.py          # Settings comuni
β”‚   β”‚   β”œβ”€β”€ dev.py           # Dev settings
β”‚   β”‚   └── prod.py          # Prod settings
β”‚   β”œβ”€β”€ urls.py               # URL router principale
β”‚   └── wsgi.py
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ auth_app/             # Microservizio Autenticazione
β”‚   β”‚   β”œβ”€β”€ models.py         # User model custom
β”‚   β”‚   β”œβ”€β”€ serializers.py
β”‚   β”‚   β”œβ”€β”€ views.py
β”‚   β”‚   └── urls.py
β”‚   β”œβ”€β”€ centraline/           # Microservizio Centraline
β”‚   β”‚   β”œβ”€β”€ models.py         # Centralina, Localizzazione
β”‚   β”‚   β”œβ”€β”€ serializers.py
β”‚   β”‚   β”œβ”€β”€ views.py
β”‚   β”‚   β”œβ”€β”€ urls.py
β”‚   β”‚   └── filters.py
β”‚   β”œβ”€β”€ dati/                 # Microservizio Dati Ambientali
β”‚   β”‚   β”œβ”€β”€ models.py         # DatiAll, MeteostatnetDati, OpasIspra
β”‚   β”‚   β”œβ”€β”€ serializers.py
β”‚   β”‚   β”œβ”€β”€ views.py
β”‚   β”‚   β”œβ”€β”€ urls.py
β”‚   β”‚   └── filters.py
β”‚   └── export/               # Microservizio Export
β”‚       β”œβ”€β”€ views.py
β”‚       β”œβ”€β”€ urls.py
β”‚       └── renderers.py      # CSV renderer custom
└── tests/
    β”œβ”€β”€ test_auth.py
    β”œβ”€β”€ test_centraline.py
    └── test_dati.py

Django - Models

# apps/centraline/models.py

from django.db import models

class Centralina(models.Model):
    name = models.CharField(max_length=255)
    alias = models.CharField(max_length=100)
    codice = models.CharField(max_length=50)
    tipo = models.CharField(max_length=20)  # ISPRA, meteostat, INVITALIA, manuale
    external_id = models.CharField(max_length=100, null=True, blank=True)
    url = models.CharField(max_length=200, null=True, blank=True)
    status = models.IntegerField(default=1)  # 1=attiva, 0=inattiva
    config_json = models.JSONField(null=True, blank=True)
    last_import = models.DateTimeField(null=True, blank=True)
    colore_mappa = models.CharField(max_length=20, null=True, blank=True)
    is_manuale = models.BooleanField(null=True, blank=True)
    raggio_mappa = models.IntegerField(null=True, blank=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        managed = False  # DB read-only
        db_table = 'centralina'

class CentralinaLocalizzazione(models.Model):
    id_centralina = models.ForeignKey(Centralina, on_delete=models.CASCADE,
                                       db_column='id_centralina')
    lat = models.DecimalField(max_digits=12, decimal_places=7, null=True)
    lng = models.DecimalField(max_digits=12, decimal_places=7, null=True)
    id_comune = models.CharField(max_length=20, null=True)
    nome_comune = models.CharField(max_length=100, null=True)
    id_provincia = models.CharField(max_length=20, null=True)
    nome_provincia = models.CharField(max_length=100, null=True)
    data_inizio = models.DateField()
    data_fine = models.DateField(null=True, blank=True)
    stato = models.IntegerField(default=1)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        managed = False
        db_table = 'centralina_localizzazione'


# apps/dati/models.py

class DatiAll(models.Model):
    id_centralina = models.ForeignKey('centraline.Centralina', on_delete=models.CASCADE,
                                       db_column='id_centralina')
    fonte = models.CharField(max_length=20)
    valori = models.TextField()
    data_ora = models.DateTimeField()
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        managed = False
        db_table = 'dati_all'

class MeteostatnetDati(models.Model):
    id_centralina = models.ForeignKey('centraline.Centralina', on_delete=models.CASCADE,
                                       db_column='id_centralina')
    time = models.DateTimeField()
    temp = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    dwpt = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    rhum = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    prcp = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    snow = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    wdir = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    wspd = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    wpgt = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    pres = models.DecimalField(max_digits=8, decimal_places=2, null=True)
    tsun = models.DecimalField(max_digits=6, decimal_places=2, null=True)
    coco = models.IntegerField(null=True)
    stato = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        managed = False
        db_table = 'meteostatnet_dati'

class OpasIspra(models.Model):
    """Modello base per le tabelle ISPRA (1793 e 1798)"""
    id_centralina = models.ForeignKey('centraline.Centralina', on_delete=models.CASCADE,
                                       db_column='id_centralina')
    so2 = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    o3 = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    no2 = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    co = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    pm2_5_fidas = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    pm10_fidas = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    benzene = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    stato = models.BooleanField(default=True)
    data_ora = models.DateTimeField()
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

class OpasIspra1793(OpasIspra):
    class Meta:
        managed = False
        db_table = 'opas_ispra_1793'

class OpasIspra1798(OpasIspra):
    class Meta:
        managed = False
        db_table = 'opas_ispra_1798'

Django - Serializers

# apps/centraline/serializers.py

from rest_framework import serializers
from .models import Centralina, CentralinaLocalizzazione

class LocalizzazioneSerializer(serializers.ModelSerializer):
    class Meta:
        model = CentralinaLocalizzazione
        fields = ['lat', 'lng', 'nome_comune', 'nome_provincia', 'data_inizio', 'data_fine']

class CentralinaListSerializer(serializers.ModelSerializer):
    localizzazione = serializers.SerializerMethodField()

    class Meta:
        model = Centralina
        fields = '__all__'

    def get_localizzazione(self, obj):
        loc = obj.centralinalocalizzazione_set.filter(stato=1).first()
        if loc:
            return LocalizzazioneSerializer(loc).data
        return None

# apps/dati/serializers.py

class MeteoSerializer(serializers.ModelSerializer):
    class Meta:
        model = MeteostatnetDati
        fields = ['id', 'id_centralina', 'time', 'temp', 'dwpt', 'rhum',
                  'prcp', 'snow', 'wdir', 'wspd', 'wpgt', 'pres', 'tsun', 'coco']

class IspraSerializer(serializers.ModelSerializer):
    class Meta:
        model = OpasIspra1793  # Stessa struttura per entrambe
        fields = ['id', 'id_centralina', 'data_ora', 'so2', 'o3', 'no2',
                  'co', 'pm2_5_fidas', 'pm10_fidas', 'benzene']

Django - Views / ViewSets

# apps/centraline/views.py

from rest_framework import viewsets, permissions
from django_filters.rest_framework import DjangoFilterBackend

class CentralinaViewSet(viewsets.ReadOnlyModelViewSet):
    """
    list:   GET /api/v1/centraline/
    detail: GET /api/v1/centraline/{id}/
    """
    queryset = Centralina.objects.all().order_by('id')
    serializer_class = CentralinaListSerializer
    permission_classes = [permissions.AllowAny]

# apps/dati/views.py

class MeteoViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = MeteoSerializer
    permission_classes = [permissions.AllowAny]
    filter_backends = [DjangoFilterBackend]

    def get_queryset(self):
        qs = MeteostatnetDati.objects.all()
        from_date = self.request.query_params.get('from')
        to_date = self.request.query_params.get('to')
        if from_date:
            qs = qs.filter(time__gte=from_date)
        if to_date:
            qs = qs.filter(time__lte=f"{to_date} 23:59:59")
        return qs.order_by('-time')[:int(self.request.query_params.get('limit', 500))]

# apps/export/views.py
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated

class ExportView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        format = request.query_params.get('format', 'json')
        # ... query logic ...
        if format == 'csv':
            return self.render_csv(data)
        return Response({'data': data})

Django - URLs

# config/urls.py

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

router = DefaultRouter()
router.register(r'centraline', CentralinaViewSet)
router.register(r'meteo', MeteoViewSet, basename='meteo')
router.register(r'ispra', IspraViewSet, basename='ispra')

urlpatterns = [
    path('api/v1/', include(router.urls)),
    path('api/v1/token/', TokenObtainPairView.as_view()),
    path('api/v1/token/refresh/', TokenRefreshView.as_view()),
    path('api/v1/export/', ExportView.as_view()),
    path('api/v1/stats/', StatsView.as_view()),
    path('api/v1/latest/', LatestView.as_view()),
]

Frontend Angular - Struttura Progetto

centraline-frontend/
β”œβ”€β”€ angular.json
β”œβ”€β”€ package.json
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”œβ”€β”€ app.config.ts
β”‚   β”‚   β”œβ”€β”€ app.routes.ts
β”‚   β”‚   β”œβ”€β”€ core/
β”‚   β”‚   β”‚   β”œβ”€β”€ services/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ api.service.ts         # HttpClient wrapper
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ auth.service.ts        # Login, logout, JWT
β”‚   β”‚   β”‚   β”‚   └── centraline.service.ts  # CRUD centraline
β”‚   β”‚   β”‚   β”œβ”€β”€ interceptors/
β”‚   β”‚   β”‚   β”‚   └── auth.interceptor.ts    # JWT auto-inject
β”‚   β”‚   β”‚   β”œβ”€β”€ guards/
β”‚   β”‚   β”‚   β”‚   └── auth.guard.ts          # Route protection
β”‚   β”‚   β”‚   └── models/
β”‚   β”‚   β”‚       β”œβ”€β”€ centralina.model.ts
β”‚   β”‚   β”‚       β”œβ”€β”€ meteo.model.ts
β”‚   β”‚   β”‚       └── ispra.model.ts
β”‚   β”‚   β”œβ”€β”€ features/
β”‚   β”‚   β”‚   β”œβ”€β”€ public/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard/
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ dashboard.component.ts
β”‚   β”‚   β”‚   β”‚   β”‚   └── dashboard.component.html
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ meteo/
β”‚   β”‚   β”‚   β”‚   β”‚   └── meteo-chart.component.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ inquinanti/
β”‚   β”‚   β”‚   β”‚   β”‚   └── inquinanti-chart.component.ts
β”‚   β”‚   β”‚   β”‚   └── mappa/
β”‚   β”‚   β”‚   β”‚       └── mappa.component.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ admin/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ config/
β”‚   β”‚   β”‚   β”‚   β”‚   └── config.component.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ export/
β”‚   β”‚   β”‚   β”‚   β”‚   └── export.component.ts
β”‚   β”‚   β”‚   β”‚   └── raw/
β”‚   β”‚   β”‚   β”‚       └── raw-explorer.component.ts
β”‚   β”‚   β”‚   └── auth/
β”‚   β”‚   β”‚       └── login/
β”‚   β”‚   β”‚           └── login.component.ts
β”‚   β”‚   └── shared/
β”‚   β”‚       β”œβ”€β”€ components/
β”‚   β”‚       β”‚   β”œβ”€β”€ navbar/
β”‚   β”‚       β”‚   β”œβ”€β”€ stat-card/
β”‚   β”‚       β”‚   β”œβ”€β”€ chart-panel/
β”‚   β”‚       β”‚   └── data-table/
β”‚   β”‚       └── pipes/
β”‚   β”‚           └── date-format.pipe.ts
β”‚   β”œβ”€β”€ environments/
β”‚   β”‚   β”œβ”€β”€ environment.ts
β”‚   β”‚   └── environment.prod.ts
β”‚   └── styles.scss

Angular - Services

// src/app/core/services/centraline.service.ts

import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { Centralina, MeteoData, IspraData, StatsData } from '../models';

@Injectable({ providedIn: 'root' })
export class CentralineService {
  private http = inject(HttpClient);
  private apiUrl = environment.apiUrl;  // es: 'https://centraline.analist24.it.com/api/v1'

  getCentraline(): Observable<Centralina[]> {
    return this.http.get<{data: Centralina[]}>(`${this.apiUrl}/centraline/`)
      .pipe(map(res => res.data));
  }

  getCentralina(id: number): Observable<Centralina> {
    return this.http.get<{data: Centralina}>(`${this.apiUrl}/centraline/${id}/`)
      .pipe(map(res => res.data));
  }

  getMeteo(from?: string, to?: string, limit = 500): Observable<MeteoData[]> {
    let params = new HttpParams();
    if (from) params = params.set('from', from);
    if (to) params = params.set('to', to);
    params = params.set('limit', limit.toString());
    return this.http.get<{data: MeteoData[]}>(`${this.apiUrl}/meteo/`, { params })
      .pipe(map(res => res.data));
  }

  getIspra(id?: number, from?: string, to?: string): Observable<IspraData[]> {
    let params = new HttpParams();
    if (id) params = params.set('id', id.toString());
    if (from) params = params.set('from', from);
    if (to) params = params.set('to', to);
    return this.http.get<{data: IspraData[]}>(`${this.apiUrl}/ispra/`, { params })
      .pipe(map(res => res.data));
  }

  getStats(id?: number, days = 30): Observable<StatsData> {
    let params = new HttpParams().set('days', days.toString());
    if (id) params = params.set('id', id.toString());
    return this.http.get<{data: StatsData}>(`${this.apiUrl}/stats/`, { params })
      .pipe(map(res => res.data));
  }
}

// src/app/core/services/auth.service.ts

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private router = inject(Router);

  login(username: string, password: string): Observable<{token: string, user: any}> {
    return this.http.post<{token: string, user: any}>(
      `${environment.apiUrl}/token/`,
      { username, password }
    ).pipe(
      tap(res => {
        localStorage.setItem('auth_token', res.token);
        localStorage.setItem('auth_user', JSON.stringify(res.user));
      })
    );
  }

  logout(): void {
    localStorage.removeItem('auth_token');
    localStorage.removeItem('auth_user');
    this.router.navigate(['/login']);
  }

  isAuthenticated(): boolean {
    return !!localStorage.getItem('auth_token');
  }

  getToken(): string | null {
    return localStorage.getItem('auth_token');
  }
}

// src/app/core/interceptors/auth.interceptor.ts

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` }
    });
  }
  return next(req);
};

Angular - Components (Esempio)

// src/app/features/public/dashboard/dashboard.component.ts

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [CommonModule, MappaComponent, MeteoChartComponent,
            InquinantiChartComponent, StatCardComponent],
  template: `
    <div class="grid grid-4">
      @for (card of statCards(); track card.label) {
        <app-stat-card [value]="card.value" [label]="card.label" [type]="card.type" />
      }
    </div>
    <app-mappa [centraline]="centraline()" />
    <app-meteo-chart [data]="meteoData()" />
    <app-inquinanti-chart [data]="ispraData()" />
  `
})
export class DashboardComponent {
  private service = inject(CentralineService);

  centraline = signal<Centralina[]>([]);
  meteoData = signal<MeteoData[]>([]);
  ispraData = signal<IspraData[]>([]);
  statCards = computed(() => this.buildStatCards());

  constructor() {
    this.loadData();
  }

  async loadData() {
    const [centraline, meteo, ispra] = await Promise.all([
      firstValueFrom(this.service.getCentraline()),
      firstValueFrom(this.service.getMeteo()),
      firstValueFrom(this.service.getIspra()),
    ]);
    this.centraline.set(centraline);
    this.meteoData.set(meteo);
    this.ispraData.set(ispra);
  }
}

Angular - Routing & Guards

// src/app/app.routes.ts

export const routes: Routes = [
  { path: '', component: DashboardComponent },
  { path: 'login', component: LoginComponent },
  {
    path: 'admin',
    canActivate: [authGuard],
    children: [
      { path: '', redirectTo: 'config', pathMatch: 'full' },
      { path: 'config', component: ConfigComponent },
      { path: 'export', component: ExportComponent },
      { path: 'raw', component: RawExplorerComponent },
    ]
  },
];

// src/app/core/guards/auth.guard.ts

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) return true;

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

Database - Schema ER

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     centralina       β”‚       β”‚  centralina_localizzazione  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ PK id                │◄──────│ FK id_centralina            β”‚
β”‚    name              β”‚       β”‚    lat, lng                 β”‚
β”‚    alias             β”‚       β”‚    nome_comune              β”‚
β”‚    codice            β”‚       β”‚    nome_provincia           β”‚
β”‚    tipo              β”‚       β”‚    data_inizio, data_fine   β”‚
β”‚    status            β”‚       β”‚    stato                    β”‚
β”‚    config_json       β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚    colore_mappa      β”‚
β”‚    is_manuale        β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    last_import       β”‚       β”‚        dati_all             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
        β”‚                      β”‚ FK id_centralina            β”‚
        │◄─────────────────────│    fonte                    β”‚
        β”‚                      β”‚    valori (text, sep=;)     β”‚
        β”‚                      β”‚    data_ora                 β”‚
        β”‚                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚
        β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚   β”‚       meteostatnet_dati               β”‚
        β”‚   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
        │◄──│ FK id_centralina                      β”‚
        β”‚   β”‚    time, temp, dwpt, rhum, prcp       β”‚
        β”‚   β”‚    snow, wdir, wspd, wpgt, pres       β”‚
        β”‚   β”‚    tsun, coco                         β”‚
        β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚
        β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚   β”‚   opas_ispra_1793 / opas_ispra_1798   β”‚
        β”‚   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
        │◄──│ FK id_centralina                      β”‚
            β”‚    data_ora                           β”‚
            β”‚    so2, o3, no2, co                   β”‚
            β”‚    pm2_5_fidas, pm10_fidas, benzene   β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Database - Dettaglio Tabelle

Database PostgreSQL: devbagnolicrm, schema: bagnoli_ambiente_dev

TabellaRecordDescrizioneTipo dati
centralina8Anagrafica centralineISPRA, meteostat, INVITALIA, manuale
centralina_localizzazione3Coordinate GPS (storicizzate)lat/lng, comune, provincia
dati_all576Dati aggregati da tutte le fontiValori separati da ;
meteostatnet_dati2.375Dati meteo orariTemperatura, umidita, vento, pressione
opas_ispra_17932.352Inquinanti centralina 1793SO2, O3, NO2, CO, PM2.5, PM10, benzene
opas_ispra_17981.680Inquinanti centralina 1798Stessi campi di 1793
invitalia0Dati cantiere InvitaliaParametri ambientali orari/giornalieri
invitalia_dati0Campioni manualiPM10, PM2.5, codici filtro

Centraline attive

IDNomeTipoCodiceZona
1MMB 274 - Bagnoli - Interno SINISPRA1793Bagnoli (NA)
2LM03 - Bagnoli - Citta della scienzaISPRA1798Bagnoli (NA)
3Napoli CapodichinometeostatMETEO-16289Capodichino
4CENTRALINA_MANUALEmanuale1122Bagnoli (NA)
7-10AR_CO_ST.1/3/4/5INVITALIAAR_CO_ST.*Cantieri Bagnoli
Nota per gli sviluppatori: Il database PostgreSQL e' in sola lettura. Non creare migrazioni Django sui modelli esistenti (usare managed = False). Il DB di autenticazione locale (SQLite) e' l'unico scrivibile.