Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
180 changes: 180 additions & 0 deletions opendata-ai-ui/app/regione/pubblico/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>;
mediana_overall: number | null;
hvd_copertura: Record<string, number>;
comuni: { nome: string; provincia: string | null; stato: string }[];
idee_top: { nome: string; priorita: number; motivo: string }[];
};

const STATO_LABEL: Record<string, string> = {
zero_dati: "Zero dati",
pochi_dati: "Pochi dati",
in_crescita: "In crescita",
maturo: "Maturo",
};
const STATO_COLOR: Record<string, string> = {
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<string, string> = {
geospatial: "Geospaziale",
earth_observation_environment: "Ambiente",
meteorological: "Meteo",
statistics: "Statistica",
companies_ownership: "Imprese",
mobility: "Mobilità",
};

export default function Page() {
const [data, setData] = useState<Pub | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="container py-5">
<div className="alert alert-danger">Dati non disponibili: {error}</div>
</div>
);
}
if (!data) return <div className="container py-5 text-muted">Caricamento…</div>;

const dist = data.distribuzione_stato;
const distTot = STATI.reduce((s, k) => s + (dist[k] ?? 0), 0) || 1;

return (
<div className="container py-4" style={{ maxWidth: 900 }}>
<h1 className="h3 mb-1">
Open data della regione{data.regione ? ` · ${data.regione}` : ""}
</h1>
<p className="text-muted">
Trasparenza pubblica: quanto i comuni della regione pubblicano e
mantengono i propri dati aperti. Dati aggregati, sola lettura.
</p>

<div className="row g-3 my-3">
<div className="col-6 col-md-3">
<div className="border rounded p-3">
<div className="h4 mb-0">{data.comuni_totali}</div>
<div className="small text-muted">Comuni</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="border rounded p-3">
<div className="h4 mb-0">{data.comuni_valutati}</div>
<div className="small text-muted">Comuni valutati</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="border rounded p-3">
<div className="h4 mb-0">
{data.mediana_overall != null ? `${data.mediana_overall.toFixed(0)}/100` : "—"}
</div>
<div className="small text-muted">Maturità mediana</div>
</div>
</div>
<div className="col-6 col-md-3">
<div className="border rounded p-3">
<div className="h4 mb-0">{dist.maturo ?? 0}</div>
<div className="small text-muted">Comuni maturi</div>
</div>
</div>
</div>

<div className="d-flex rounded overflow-hidden mb-2" style={{ height: 22 }}>
{STATI.map((k) =>
dist[k] ? (
<div
key={k}
style={{ width: `${((dist[k] ?? 0) / distTot) * 100}%`, background: STATO_COLOR[k] }}
title={`${STATO_LABEL[k]}: ${dist[k]}`}
/>
) : null,
)}
</div>
<div className="d-flex flex-wrap gap-3 small mb-4">
{STATI.map((k) => (
<span key={k}>
<span
className="d-inline-block rounded-circle me-1"
style={{ width: 10, height: 10, background: STATO_COLOR[k] }}
/>
{STATO_LABEL[k]}: <strong>{dist[k] ?? 0}</strong>
</span>
))}
</div>

{data.idee_top.length > 0 && (
<div className="mb-4">
<h2 className="h6">Dataset prioritari per la regione</h2>
<ol className="mb-0">
{data.idee_top.map((i) => (
<li key={i.nome}>
<strong>{i.nome}</strong> — <span className="text-muted">{i.motivo}</span>
</li>
))}
</ol>
</div>
)}

<h2 className="h6">Copertura per categoria di dati (HVD)</h2>
<ul className="list-unstyled mb-4">
{Object.entries(data.hvd_copertura).map(([cat, frac]) => (
<li key={cat} className="mb-1">
<span className="d-inline-block" style={{ width: 130 }}>
{HVD_LABEL[cat] ?? cat}
</span>
<span className="badge bg-primary-subtle text-primary-emphasis">
{(frac * 100).toFixed(0)}%
</span>
</li>
))}
</ul>

<h2 className="h6">Stato dei comuni</h2>
<div className="d-flex flex-wrap gap-2">
{data.comuni.map((c) => (
<span
key={c.nome}
className="badge"
style={{ background: STATO_COLOR[c.stato] }}
title={STATO_LABEL[c.stato] ?? c.stato}
>
{c.nome}
</span>
))}
</div>
</div>
);
}
39 changes: 39 additions & 0 deletions opendata-backend/src/opendata_backend/region/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
13 changes: 13 additions & 0 deletions opendata-backend/src/opendata_backend/routers/region.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 23 additions & 0 deletions opendata-backend/tests/test_region_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading