feat: Controllo accessi RFID completo con gestione sessioni

- Aggiunto supporto multi-pattern RFID (US/IT layout)
- Implementata invalidazione sessioni al restart del server
- Schermata "badge non trovato" con countdown 30s
- Notifica quando badge validatore passato senza utente
- Database aggiornato con badge reali di test
- Layout ottimizzato per tablet orizzontale
- Banner NumLock per desktop
- Toggle visibilità password
- Carosello benvenuto multilingua (10 lingue)
- Pagina debug RFID (/debug)
This commit is contained in:
2026-01-17 20:06:50 +01:00
parent 21b509c6ba
commit e68f299feb
48 changed files with 3625 additions and 2445 deletions

View File

@@ -28,3 +28,5 @@ Thumbs.db
# Local environment
.env
.env.local

View File

@@ -4,14 +4,14 @@ verify_ssl = true
name = "pypi"
[packages]
fastapi = ">=0.109.0"
uvicorn = {extras = ["standard"], version = ">=0.27.0"}
pydantic = ">=2.5.0"
fastapi = "*"
uvicorn = "*"
pydantic = "*"
[dev-packages]
[requires]
python_version = "3.10"
python_version = "3.14"
[scripts]
start = "python main.py"

View File

@@ -0,0 +1,7 @@
"""
Focolari Voting System - API Package
"""
from .routes import router, init_data
__all__ = ["router", "init_data"]

153
backend-mock/api/routes.py Normal file
View File

@@ -0,0 +1,153 @@
"""
Focolari Voting System - API Routes
"""
import time
from fastapi import APIRouter, HTTPException
from schemas import (
LoginRequest,
EntryRequest,
UserResponse,
RoomInfoResponse,
LoginResponse,
EntryResponse,
)
router = APIRouter()
# Timestamp di avvio del server (per invalidare sessioni frontend)
SERVER_START_TIME = int(time.time() * 1000)
# Dati caricati dinamicamente dal main
_data = {
"validator_password": "",
"room": {"room_name": "", "meeting_id": ""},
"users": []
}
# Caratteri sentinel da pulire (tutti i layout supportati)
SENTINEL_CHARS = [";", "?", "ò", "_", "ç", "+"]
def init_data(data: dict):
"""Inizializza i dati caricati dal file JSON"""
global _data
_data = data
def clean_badge(badge: str) -> str:
"""Rimuove i caratteri sentinel dal badge"""
clean = badge.strip()
for char in SENTINEL_CHARS:
clean = clean.replace(char, "")
return clean
def find_user(badge_code: str) -> dict | None:
"""Cerca un utente per badge code"""
clean = clean_badge(badge_code)
for user in _data["users"]:
if user["badge_code"] == clean or user["badge_code"].lstrip("0") == clean.lstrip("0"):
return user
return None
@router.get("/info-room", response_model=RoomInfoResponse)
async def get_room_info():
"""Restituisce le informazioni sulla sala e la riunione corrente."""
return RoomInfoResponse(
room_name=_data["room"]["room_name"],
meeting_id=_data["room"]["meeting_id"],
server_start_time=SERVER_START_TIME
)
@router.post("/login-validate", response_model=LoginResponse)
async def login_validate(request: LoginRequest):
"""
Valida la password del validatore.
Il badge viene passato dal frontend ma attualmente non viene verificato
lato server - serve solo per essere memorizzato nella sessione frontend.
TODO: Concordare con committenti se il badge debba essere verificato
anche lato server (es. lista badge validatori autorizzati).
"""
if request.password != _data["validator_password"]:
raise HTTPException(
status_code=401,
detail="Password non corretta"
)
# Il badge viene restituito per conferma, ma non è validato lato server
clean = clean_badge(request.badge) if request.badge else "unknown"
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=f"focolare-token-{clean}"
)
@router.get("/anagrafica/{badge_code}", response_model=UserResponse)
async def get_user_anagrafica(badge_code: str):
"""
Cerca un utente tramite il suo badge code.
"""
user = find_user(badge_code)
if not user:
clean = clean_badge(badge_code)
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean} non trovato nel sistema"
)
response = UserResponse(
badge_code=user["badge_code"],
nome=user["nome"],
cognome=user["cognome"],
url_foto=user["url_foto"],
ruolo=user["ruolo"],
ammesso=user["ammesso"]
)
if not user["ammesso"]:
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!"
return response
@router.post("/entry-request", response_model=EntryResponse)
async def process_entry_request(request: EntryRequest):
"""
Processa una richiesta di ingresso.
Risposta asettica senza messaggi multilingua.
"""
if request.validator_password != _data["validator_password"]:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
user = find_user(request.user_badge)
if not user:
clean = clean_badge(request.user_badge)
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean} non trovato"
)
if not user["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user['nome']} {user['cognome']} NON è autorizzato all'ingresso"
)
return EntryResponse(
success=True,
message=f"Ingresso registrato per {user['nome']} {user['cognome']}"
)

View File

@@ -0,0 +1,33 @@
{
"validator_password": "focolari",
"room": {
"room_name": "Sala Assemblea",
"meeting_id": "VOT-2024"
},
"users": [
{
"badge_code": "0008988288",
"nome": "Marco",
"cognome": "Bianchi",
"url_foto": "https://randomuser.me/api/portraits/men/1.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "0007399575",
"nome": "Laura",
"cognome": "Rossi",
"url_foto": "https://randomuser.me/api/portraits/women/2.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "0000514162",
"nome": "Giuseppe",
"cognome": "Verdi",
"url_foto": "https://randomuser.me/api/portraits/men/3.jpg",
"ruolo": "Tecnico",
"ammesso": false
}
]
}

View File

@@ -0,0 +1,33 @@
{
"validator_password": "test123",
"room": {
"room_name": "Sala Test",
"meeting_id": "TEST-001"
},
"users": [
{
"badge_code": "111111",
"nome": "Test",
"cognome": "Ammesso",
"url_foto": "https://randomuser.me/api/portraits/lego/1.jpg",
"ruolo": "Votante",
"ammesso": true
},
{
"badge_code": "222222",
"nome": "Test",
"cognome": "NonAmmesso",
"url_foto": "https://randomuser.me/api/portraits/lego/2.jpg",
"ruolo": "Ospite",
"ammesso": false
},
{
"badge_code": "333333",
"nome": "Test",
"cognome": "Tecnico",
"url_foto": "https://randomuser.me/api/portraits/lego/3.jpg",
"ruolo": "Tecnico",
"ammesso": true
}
]
}

View File

@@ -1,277 +1,191 @@
"""
Focolari Voting System - Backend Mock
Sistema di controllo accessi per votazioni del Movimento dei Focolari
Utilizzo:
python main.py # Default: porta 8000, dati default
python main.py -p 9000 # Porta custom
python main.py -d data/users_test.json # Dataset custom
python main.py -p 9000 -d data/users_test.json
"""
import random
from fastapi import FastAPI, HTTPException
import argparse
import json
import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
app = FastAPI(
title="Focolari Voting System API",
description="Backend mock per il sistema di controllo accessi",
version="1.0.0"
)
from api.routes import router, init_data
# CORS abilitato per tutti
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================
# MODELLI PYDANTIC
# ============================================
class LoginRequest(BaseModel):
badge: str
password: str
class EntryRequest(BaseModel):
user_badge: str
validator_password: str
class UserResponse(BaseModel):
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: str
ammesso: bool
warning: Optional[str] = None
class RoomInfoResponse(BaseModel):
room_name: str
meeting_id: str
class LoginResponse(BaseModel):
success: bool
message: str
token: Optional[str] = None
class EntryResponse(BaseModel):
success: bool
message: str
welcome_message: Optional[str] = None
# ============================================
# DATI MOCK
# ============================================
# Credenziali Validatore
VALIDATOR_BADGE = "999999"
VALIDATOR_PASSWORD = "focolari"
MOCK_TOKEN = "focolare-validator-token-2024"
# Lista utenti mock
USERS_DB = [
{
"badge_code": "000001",
"nome": "Maria",
"cognome": "Rossi",
"url_foto": "https://randomuser.me/api/portraits/women/1.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000002",
"nome": "Giuseppe",
"cognome": "Bianchi",
"url_foto": "https://randomuser.me/api/portraits/men/2.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000003",
"nome": "Anna",
"cognome": "Verdi",
"url_foto": "https://randomuser.me/api/portraits/women/3.jpg",
"ruolo": "Tecnico",
"ammesso": True
},
{
"badge_code": "000004",
"nome": "Francesco",
"cognome": "Neri",
"url_foto": "https://randomuser.me/api/portraits/men/4.jpg",
"ruolo": "Ospite",
"ammesso": False # Non ammesso!
},
{
"badge_code": "000005",
"nome": "Lucia",
"cognome": "Gialli",
"url_foto": "https://randomuser.me/api/portraits/women/5.jpg",
"ruolo": "Votante",
"ammesso": True
},
{
"badge_code": "000006",
"nome": "Paolo",
"cognome": "Blu",
"url_foto": "https://randomuser.me/api/portraits/men/6.jpg",
"ruolo": "Votante",
"ammesso": False # Non ammesso!
},
{
"badge_code": "123456",
"nome": "Teresa",
"cognome": "Martini",
"url_foto": "https://randomuser.me/api/portraits/women/7.jpg",
"ruolo": "Votante",
"ammesso": True
},
]
# Messaggi di benvenuto multilingua
WELCOME_MESSAGES = [
"Benvenuto! / Welcome!",
"Bienvenue! / Willkommen!",
"Bienvenido! / Bem-vindo!",
"欢迎! / 歓迎!",
"Добро пожаловать! / مرحبا!"
]
# ============================================
# ENDPOINTS
# ============================================
@app.get("/")
async def root():
"""Endpoint di test per verificare che il server sia attivo"""
return {"status": "ok", "message": "Focolari Voting System API is running"}
# Configurazione default
DEFAULT_PORT = 8000
DEFAULT_HOST = "0.0.0.0"
DEFAULT_DATA = "data/users_default.json"
STATIC_DIR = Path(__file__).parent.parent / "frontend" / "dist"
@app.get("/info-room", response_model=RoomInfoResponse)
async def get_room_info():
"""
Restituisce le informazioni sulla sala e la riunione corrente.
"""
return RoomInfoResponse(
room_name="Sala Assemblea",
meeting_id="VOT-2024"
def load_data(data_path: str) -> dict:
"""Carica i dati dal file JSON"""
path = Path(data_path)
if not path.is_absolute():
# Path relativo alla directory del main.py
base_dir = Path(__file__).parent
path = base_dir / path
if not path.exists():
print(f"❌ Errore: File dati non trovato: {path}")
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"📂 Dati caricati da: {path}")
print(f" - Password validatore: {'*' * len(data['validator_password'])}")
print(f" - Sala: {data['room']['room_name']}")
print(f" - Utenti: {len(data['users'])}")
return data
except json.JSONDecodeError as e:
print(f"❌ Errore parsing JSON: {e}")
sys.exit(1)
except KeyError as e:
print(f"❌ Errore struttura JSON: chiave mancante {e}")
sys.exit(1)
def create_app(data: dict, serve_frontend: bool = True) -> FastAPI:
"""Crea e configura l'applicazione FastAPI"""
app = FastAPI(
title="Focolari Voting System API",
description="Backend mock per il sistema di controllo accessi",
version="1.0.0"
)
@app.post("/login-validate", response_model=LoginResponse)
async def login_validate(request: LoginRequest):
"""
Valida le credenziali del validatore.
Il badge deve essere quello del validatore (999999) e la password corretta.
"""
# Pulisci il badge da eventuali caratteri sentinel
clean_badge = request.badge.strip().replace(";", "").replace("?", "")
if clean_badge != VALIDATOR_BADGE:
raise HTTPException(
status_code=401,
detail="Badge validatore non riconosciuto"
)
if request.password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password non corretta"
)
return LoginResponse(
success=True,
message="Login validatore effettuato con successo",
token=MOCK_TOKEN
# CORS abilitato per tutti (utile in sviluppo)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Inizializza i dati nelle routes
init_data(data)
@app.get("/anagrafica/{badge_code}", response_model=UserResponse)
async def get_user_anagrafica(badge_code: str):
"""
Cerca un utente tramite il suo badge code.
Restituisce i dati anagrafici e un warning se non è ammesso.
"""
# Pulisci il badge da eventuali caratteri sentinel
clean_badge = badge_code.strip().replace(";", "").replace("?", "")
# Registra le routes API
app.include_router(router)
# Normalizza: rimuovi zeri iniziali per il confronto, ma cerca anche con zeri
for user in USERS_DB:
if user["badge_code"] == clean_badge or user["badge_code"].lstrip("0") == clean_badge.lstrip("0"):
response = UserResponse(
badge_code=user["badge_code"],
nome=user["nome"],
cognome=user["cognome"],
url_foto=user["url_foto"],
ruolo=user["ruolo"],
ammesso=user["ammesso"]
)
# Serve frontend statico se la cartella esiste
if serve_frontend and STATIC_DIR.exists():
print(f"🌐 Frontend statico servito da: {STATIC_DIR}")
# Aggiungi warning se non ammesso
if not user["ammesso"]:
response.warning = "ATTENZIONE: Questo utente NON è autorizzato all'ingresso!"
# Serve index.html per la root e tutte le route SPA
@app.get("/")
async def serve_index():
return FileResponse(STATIC_DIR / "index.html")
return response
@app.get("/debug")
async def serve_debug():
return FileResponse(STATIC_DIR / "index.html")
# Utente non trovato
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_badge} non trovato nel sistema"
# Monta i file statici (JS, CSS, assets) - DEVE essere dopo le route
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# Fallback per altri file statici nella root (favicon, ecc.)
@app.get("/{filename:path}")
async def serve_static(filename: str):
file_path = STATIC_DIR / filename
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Per route SPA sconosciute, serve index.html
return FileResponse(STATIC_DIR / "index.html")
else:
# API-only mode
@app.get("/")
async def root():
"""Endpoint di test per verificare che il server sia attivo"""
return {
"status": "ok",
"message": "Focolari Voting System API is running",
"room": data["room"]["room_name"],
"frontend": "not built - run 'npm run build' in frontend/"
}
return app
def parse_args():
"""Parse degli argomenti da linea di comando"""
parser = argparse.ArgumentParser(
description="Focolari Voting System - Backend Mock Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Esempi:
python main.py # Avvio standard
python main.py -p 9000 # Porta 9000
python main.py -d data/users_test.json # Dataset test
python main.py --host 127.0.0.1 -p 8080 # Solo localhost
python main.py --api-only # Solo API, no frontend
"""
)
@app.post("/entry-request", response_model=EntryResponse)
async def process_entry_request(request: EntryRequest):
"""
Processa una richiesta di ingresso.
Richiede il badge dell'utente e la password del validatore.
"""
# Pulisci i dati
clean_user_badge = request.user_badge.strip().replace(";", "").replace("?", "")
# Verifica password validatore
if request.validator_password != VALIDATOR_PASSWORD:
raise HTTPException(
status_code=401,
detail="Password validatore non corretta"
)
# Cerca l'utente
user_found = None
for user in USERS_DB:
if user["badge_code"] == clean_user_badge or user["badge_code"].lstrip("0") == clean_user_badge.lstrip("0"):
user_found = user
break
if not user_found:
raise HTTPException(
status_code=404,
detail=f"Utente con badge {clean_user_badge} non trovato"
)
if not user_found["ammesso"]:
raise HTTPException(
status_code=403,
detail=f"L'utente {user_found['nome']} {user_found['cognome']} NON è autorizzato all'ingresso"
)
# Successo! Genera messaggio di benvenuto casuale
welcome = random.choice(WELCOME_MESSAGES)
return EntryResponse(
success=True,
message=f"Ingresso registrato per {user_found['nome']} {user_found['cognome']}",
welcome_message=welcome
parser.add_argument(
"-p", "--port",
type=int,
default=DEFAULT_PORT,
help=f"Porta del server (default: {DEFAULT_PORT})"
)
parser.add_argument(
"-d", "--data",
type=str,
default=DEFAULT_DATA,
help=f"Path al file JSON con i dati (default: {DEFAULT_DATA})"
)
parser.add_argument(
"--host",
type=str,
default=DEFAULT_HOST,
help=f"Host di binding (default: {DEFAULT_HOST})"
)
parser.add_argument(
"--api-only",
action="store_true",
help="Avvia solo le API senza servire il frontend"
)
return parser.parse_args()
def main():
"""Entry point principale"""
args = parse_args()
print("🚀 Avvio Focolari Voting System Backend...")
print("=" * 50)
# Carica i dati
data = load_data(args.data)
print("=" * 50)
print(f"📍 Server in ascolto su http://{args.host}:{args.port}")
print(f"📚 Documentazione API su http://{args.host}:{args.port}/docs")
if not args.api_only and STATIC_DIR.exists():
print(f"🌐 Frontend disponibile su http://{args.host}:{args.port}/")
print("=" * 50)
# Crea e avvia l'app
app = create_app(data, serve_frontend=not args.api_only)
uvicorn.run(app, host=args.host, port=args.port)
# ============================================
# AVVIO SERVER
# ============================================
if __name__ == "__main__":
import uvicorn
print("🚀 Avvio Focolari Voting System Backend...")
print("📍 Server in ascolto su http://localhost:8000")
print("📚 Documentazione API su http://localhost:8000/docs")
uvicorn.run(app, host="0.0.0.0", port=8000)
main()

View File

@@ -0,0 +1,21 @@
"""
Focolari Voting System - Schemas Package
"""
from .models import (
LoginRequest,
EntryRequest,
UserResponse,
RoomInfoResponse,
LoginResponse,
EntryResponse,
)
__all__ = [
"LoginRequest",
"EntryRequest",
"UserResponse",
"RoomInfoResponse",
"LoginResponse",
"EntryResponse",
]

View File

@@ -0,0 +1,50 @@
"""
Focolari Voting System - Modelli Pydantic
"""
from typing import Optional, Literal
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Richiesta login validatore"""
badge: str
password: str
class EntryRequest(BaseModel):
"""Richiesta ingresso partecipante"""
user_badge: str
validator_password: str
class UserResponse(BaseModel):
"""Risposta dati utente"""
badge_code: str
nome: str
cognome: str
url_foto: str
ruolo: Literal["Tecnico", "Votante", "Ospite"]
ammesso: bool
warning: Optional[str] = None
class RoomInfoResponse(BaseModel):
"""Risposta info sala"""
room_name: str
meeting_id: str
server_start_time: int # Timestamp avvio server per invalidare sessioni
class LoginResponse(BaseModel):
"""Risposta login"""
success: bool
message: str
token: Optional[str] = None
class EntryResponse(BaseModel):
"""Risposta richiesta ingresso (asettica, senza welcome_message)"""
success: bool
message: str