From 871efb934710a402d980055f673a6ad71c30592e Mon Sep 17 00:00:00 2001 From: Giuseppe Zileni Date: Tue, 21 Jul 2026 19:16:05 +0200 Subject: [PATCH] feat(region): vista pubblica di trasparenza /regione/pubblico (#232, F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unico endpoint PUBBLICO (no auth) oltre /health, eccezione deliberata a R7: aggregati regionali NON sensibili (nessun dato personale) — distribuzione stato, mediana, copertura HVD, stato qualitativo dei comuni, top 5 priorità. Protetto dal rate limit per-IP (#237). UI /regione/pubblico read-only, senza DashboardGate/token. R7 aggiornata con l'eccezione. Backend 510, tsc+build+lint OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 +- opendata-ai-ui/app/regione/pubblico/page.tsx | 180 ++++++++++++++++++ .../src/opendata_backend/region/service.py | 39 ++++ .../src/opendata_backend/routers/region.py | 13 ++ opendata-backend/tests/test_region_backend.py | 23 +++ 5 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 opendata-ai-ui/app/regione/pubblico/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 932353d7..961a4309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,10 +262,13 @@ a **How to apply** (when it kicks in). `docs/cruscotto-regionale.md` §6-bis). `AUTH_ENABLED=false` (dev) makes `require_user` return a synthetic `dev-user`. - **How to apply:** set `OIDC_ISSUER` (legacy alias `CLERK_JWT_ISSUER` still - accepted) and optionally `OIDC_AUDIENCE`. Never add anonymous endpoints — new - routes go through `Depends(require_user)` or live under `/health`. The public - read-only cruscotto view is served by a dedicated aggregated endpoint, not by - making routes anonymous. Clerk-specific extras (`CLERK_SECRET_KEY`, + accepted) and optionally `OIDC_AUDIENCE`. New routes go through + `Depends(require_user)` or live under `/health`. **Sole public exception:** + `GET /regione/pubblico` (transparency view, F5 of #227) is anonymous by design + — it returns only **aggregated, non-PII** regional data and is protected by the + per-IP rate-limit middleware. Do not add other anonymous endpoints; if you + extend the public view, keep the payload aggregated and free of personal data. + Clerk-specific extras (`CLERK_SECRET_KEY`, `CLERK_WEBHOOK_SECRET`, app `app_3EMALiLi0UTULl89JPMKtaLENoy` in `.clerk/config.md`) apply *only* when the IdP is Clerk. RBAC/roles + registration (SPID + local email-OTP) + admin dashboard are a separate design step (#235). diff --git a/opendata-ai-ui/app/regione/pubblico/page.tsx b/opendata-ai-ui/app/regione/pubblico/page.tsx new file mode 100644 index 00000000..19ccd8f9 --- /dev/null +++ b/opendata-ai-ui/app/regione/pubblico/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { apiFetch } from "@/lib/api"; + +// Vista PUBBLICA di trasparenza (#232, F5): read-only, senza login. Consuma +// l'unico endpoint pubblico /regione/pubblico (aggregati, no dati personali). +// Nessun DashboardGate, nessun token. + +type Pub = { + regione: string; + comuni_totali: number; + comuni_valutati: number; + distribuzione_stato: Record; + mediana_overall: number | null; + hvd_copertura: Record; + comuni: { nome: string; provincia: string | null; stato: string }[]; + idee_top: { nome: string; priorita: number; motivo: string }[]; +}; + +const STATO_LABEL: Record = { + zero_dati: "Zero dati", + pochi_dati: "Pochi dati", + in_crescita: "In crescita", + maturo: "Maturo", +}; +const STATO_COLOR: Record = { + zero_dati: "#D9364F", + pochi_dati: "#E58A00", + in_crescita: "#0066CC", + maturo: "#008758", +}; +const STATI = ["zero_dati", "pochi_dati", "in_crescita", "maturo"] as const; +const HVD_LABEL: Record = { + geospatial: "Geospaziale", + earth_observation_environment: "Ambiente", + meteorological: "Meteo", + statistics: "Statistica", + companies_ownership: "Imprese", + mobility: "Mobilità", +}; + +export default function Page() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + (async () => { + try { + const res = await apiFetch("/regione/pubblico"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (alive) setData(await res.json()); + } catch (e) { + if (alive) setError(e instanceof Error ? e.message : String(e)); + } + })(); + return () => { + alive = false; + }; + }, []); + + if (error) { + return ( +
+
Dati non disponibili: {error}
+
+ ); + } + if (!data) return
Caricamento…
; + + const dist = data.distribuzione_stato; + const distTot = STATI.reduce((s, k) => s + (dist[k] ?? 0), 0) || 1; + + return ( +
+

+ Open data della regione{data.regione ? ` · ${data.regione}` : ""} +

+

+ Trasparenza pubblica: quanto i comuni della regione pubblicano e + mantengono i propri dati aperti. Dati aggregati, sola lettura. +

+ +
+
+
+
{data.comuni_totali}
+
Comuni
+
+
+
+
+
{data.comuni_valutati}
+
Comuni valutati
+
+
+
+
+
+ {data.mediana_overall != null ? `${data.mediana_overall.toFixed(0)}/100` : "—"} +
+
Maturità mediana
+
+
+
+
+
{dist.maturo ?? 0}
+
Comuni maturi
+
+
+
+ +
+ {STATI.map((k) => + dist[k] ? ( +
+ ) : null, + )} +
+
+ {STATI.map((k) => ( + + + {STATO_LABEL[k]}: {dist[k] ?? 0} + + ))} +
+ + {data.idee_top.length > 0 && ( +
+

Dataset prioritari per la regione

+
    + {data.idee_top.map((i) => ( +
  1. + {i.nome}{i.motivo} +
  2. + ))} +
+
+ )} + +

Copertura per categoria di dati (HVD)

+
    + {Object.entries(data.hvd_copertura).map(([cat, frac]) => ( +
  • + + {HVD_LABEL[cat] ?? cat} + + + {(frac * 100).toFixed(0)}% + +
  • + ))} +
+ +

Stato dei comuni

+
+ {data.comuni.map((c) => ( + + {c.nome} + + ))} +
+
+ ); +} diff --git a/opendata-backend/src/opendata_backend/region/service.py b/opendata-backend/src/opendata_backend/region/service.py index eeac9f16..988cb47f 100644 --- a/opendata-backend/src/opendata_backend/region/service.py +++ b/opendata-backend/src/opendata_backend/region/service.py @@ -165,6 +165,45 @@ async def comuni( } +async def public_overview(session: AsyncSession, settings: Settings) -> dict[str, Any]: + """Sottoinsieme PUBBLICO (read-only, no PII) per la trasparenza: aggregati + regionali + stato qualitativo dei comuni + top priorità. Nessun dato + personale — solo comuni, dataset e maturità (tutti pubblici).""" + summaries, prov = await _load_summaries(session, settings) + ov = aggregate_region( + summaries, + regione=region_name(settings) or "", + cod_regione=settings.region_istat or "", + comuni_totali=len(summaries), + ) + comuni = [ + { + "nome": s.nome, + "provincia": prov.get(s.istat), + "stato": accompaniment_state(n_dataset=s.n_dataset, overall=s.overall).stato, + } + for s in sorted(summaries, key=lambda s: s.nome) + ] + ranked = prioritize(load_catalog()) + candidates = [ + IdeaCandidate(id=r.candidate.id, nome=r.candidate.nome, area=r.candidate.area, + hvd=r.candidate.hvd, valore=r.valore) + for r in ranked + ] + idee = regional_ideas(candidates, summaries, comuni_totali=len(summaries))[:5] + return { + "regione": ov.regione, + "cod_regione": ov.cod_regione, + "comuni_totali": ov.comuni_totali, + "comuni_valutati": ov.comuni_valutati, + "distribuzione_stato": ov.distribuzione_stato, + "mediana_overall": ov.mediana_overall, + "hvd_copertura": ov.hvd_copertura, + "comuni": comuni, + "idee_top": [{"nome": i.nome, "priorita": i.priorita, "motivo": i.motivo} for i in idee], + } + + async def ideas(session: AsyncSession, settings: Settings) -> dict[str, Any]: """Proposte a livello regionale: i dataset candidati Copilota ordinati per priorità = valore pesato dal gap di copertura tra i comuni della regione.""" diff --git a/opendata-backend/src/opendata_backend/routers/region.py b/opendata-backend/src/opendata_backend/routers/region.py index b07e51d8..208d1d0c 100644 --- a/opendata-backend/src/opendata_backend/routers/region.py +++ b/opendata-backend/src/opendata_backend/routers/region.py @@ -48,3 +48,16 @@ async def idee( _user: ClerkUser = Depends(enforce_rate_limit), ) -> dict[str, Any]: return await region_service.ideas(session, settings) + + +@router.get("/pubblico") +async def pubblico( + session: AsyncSession = Depends(get_db_session), + settings: Settings = Depends(get_settings), +) -> dict[str, Any]: + """Vista PUBBLICA di trasparenza (F5) — **senza autenticazione**. + + Eccezione deliberata a R7 (unico endpoint pubblico oltre `/health`): espone + solo aggregati regionali NON sensibili (nessun dato personale). Protetto dal + rate limit per-IP del middleware (#237). Nessun `Depends(require_user)`.""" + return await region_service.public_overview(session, settings) diff --git a/opendata-backend/tests/test_region_backend.py b/opendata-backend/tests/test_region_backend.py index ebd8ea2c..0d13b290 100644 --- a/opendata-backend/tests/test_region_backend.py +++ b/opendata-backend/tests/test_region_backend.py @@ -120,6 +120,29 @@ def test_empty_when_region_has_no_comuni(sm) -> None: assert body["dove_intervenire"] == [] +def test_pubblico_is_anonymous_and_aggregated(sm) -> None: + # Nessun override di require_user: l'endpoint pubblico non lo richiede. + async def _db(): + async with sm() as session: + yield session + + from fastapi import FastAPI + from starlette.testclient import TestClient + app = FastAPI() + app.include_router(region.router) + app.dependency_overrides[get_settings] = lambda: Settings( # type: ignore[call-arg] + auth_enabled=True, region_istat="16", + ) + app.dependency_overrides[get_db_session] = _db + body = TestClient(app).get("/regione/pubblico").json() + + assert body["comuni_totali"] == 3 + assert body["cod_regione"] == "16" + # payload aggregato: stato qualitativo per comune, nessun campo personale. + assert {"nome", "provincia", "stato"} == set(body["comuni"][0].keys()) + assert "idee_top" in body and len(body["idee_top"]) <= 5 + + def test_idee_regionali_ranked(sm) -> None: body = _client(sm).get("/regione/idee").json() assert body["cod_regione"] == "16"