v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg

This commit is contained in:
2026-04-12 17:46:08 +00:00
commit c33ec8450e
31 changed files with 3072 additions and 0 deletions

0
app/routers/__init__.py Normal file
View File

258
app/routers/admin.py Normal file
View File

@@ -0,0 +1,258 @@
"""Endpoint admin — protetti con API key. Vista operatore."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import text
from app.database import get_db
from app.models import Service, Provider, ProviderService, Booking
from app.schemas import ServiceCreate, ServiceOut, ProviderCreate, ProviderOut, BookingOut, BookingUpdate
from app.config import get_settings
router = APIRouter(prefix="/api/admin", tags=["admin"])
settings = get_settings()
TZ = ZoneInfo("Europe/Rome")
DAYS = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom']
def verify_api_key(x_api_key: str = Header(...)):
if x_api_key != settings.api_key:
raise HTTPException(401, "API key non valida")
return True
# === Bookings ===
@router.get("/bookings", dependencies=[Depends(verify_api_key)])
def list_bookings(
date: date | None = None,
from_date: date | None = None,
to_date: date | None = None,
status: str | None = None,
provider_id: int | None = None,
page: int = 1,
per_page: int = 20,
db: Session = Depends(get_db),
):
q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider))
if date:
day_start = datetime.combine(date, datetime.min.time(), tzinfo=TZ)
q = q.filter(Booking.start_at >= day_start, Booking.start_at < day_start + timedelta(days=1))
elif from_date and to_date:
q = q.filter(
Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ),
Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ),
)
if status:
q = q.filter(Booking.status == status)
if provider_id:
q = q.filter(Booking.provider_id == provider_id)
total = q.count()
items = q.order_by(Booking.start_at.desc()).offset((page-1)*per_page).limit(per_page).all()
return {"items": [BookingOut.model_validate(b) for b in items], "total": total, "page": page, "per_page": per_page, "pages": (total + per_page - 1) // per_page}
@router.put("/bookings/{booking_id}", response_model=BookingOut, dependencies=[Depends(verify_api_key)])
def update_booking(booking_id: int, data: BookingUpdate, db: Session = Depends(get_db)):
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(404)
if data.status:
booking.status = data.status
if data.notes is not None:
booking.notes = data.notes
db.commit()
db.refresh(booking)
return db.query(Booking).options(
joinedload(Booking.service), joinedload(Booking.provider)
).filter(Booking.id == booking_id).first()
# === Services CRUD ===
@router.get("/services", response_model=list[ServiceOut], dependencies=[Depends(verify_api_key)])
def admin_list_services(db: Session = Depends(get_db)):
return db.query(Service).order_by(Service.sort_order, Service.name).all()
@router.post("/services", response_model=ServiceOut, dependencies=[Depends(verify_api_key)])
def create_service(data: ServiceCreate, db: Session = Depends(get_db)):
s = Service(**data.model_dump())
db.add(s)
db.commit()
db.refresh(s)
return s
@router.put("/services/{service_id}", response_model=ServiceOut, dependencies=[Depends(verify_api_key)])
def update_service(service_id: int, data: ServiceCreate, db: Session = Depends(get_db)):
s = db.query(Service).filter(Service.id == service_id).first()
if not s:
raise HTTPException(404)
for k, v in data.model_dump().items():
setattr(s, k, v)
db.commit()
db.refresh(s)
return s
@router.delete("/services/{service_id}", dependencies=[Depends(verify_api_key)])
def delete_service(service_id: int, db: Session = Depends(get_db)):
s = db.query(Service).filter(Service.id == service_id).first()
if not s:
raise HTTPException(404)
db.delete(s)
db.commit()
return {"ok": True}
# === Providers CRUD ===
@router.get("/providers", response_model=list[ProviderOut], dependencies=[Depends(verify_api_key)])
def admin_list_providers(db: Session = Depends(get_db)):
return db.query(Provider).filter(Provider.active == True).all()
@router.post("/providers", dependencies=[Depends(verify_api_key)])
def create_provider(data: ProviderCreate, db: Session = Depends(get_db)):
p = Provider(**data.model_dump())
db.add(p)
db.commit()
db.refresh(p)
return {"id": p.id, "name": p.name}
# === Provider detail with service assignments ===
@router.get("/providers/detail", dependencies=[Depends(verify_api_key)])
def providers_detail(db: Session = Depends(get_db)):
"""Tutti i provider con i loro servizi assegnati e regole orarie."""
providers = db.query(Provider).filter(Provider.active == True).order_by(Provider.id).all()
result = []
for p in providers:
assignments = db.query(ProviderService).filter(ProviderService.provider_id == p.id).all()
services = []
for a in assignments:
svc = db.query(Service).filter(Service.id == a.service_id).first()
if svc:
services.append({
"assignment_id": a.id,
"service_id": svc.id,
"service_name": svc.name,
"service_slug": svc.slug,
"duration_min": svc.duration_min,
"category": svc.category,
"availability_rules": a.availability_rules or [],
})
result.append({
"id": p.id,
"name": p.name,
"email": p.email,
"phone": p.phone,
"services": services,
})
return result
# === Provider-Service assignment CRUD ===
@router.post("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def assign_service(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)):
"""Assegna servizio a operatore con regole orarie.
Body: [{"weekday":0,"start":"09:00","end":"13:00"}, ...]
"""
existing = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if existing:
existing.availability_rules = rules
else:
ps = ProviderService(provider_id=provider_id, service_id=service_id, availability_rules=rules)
db.add(ps)
db.commit()
return {"ok": True}
@router.put("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def update_assignment(provider_id: int, service_id: int, rules: list[dict], db: Session = Depends(get_db)):
"""Aggiorna le regole orarie di un'assegnazione."""
ps = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if not ps:
raise HTTPException(404, "Assegnazione non trovata")
ps.availability_rules = rules
db.commit()
return {"ok": True}
@router.delete("/providers/{provider_id}/services/{service_id}", dependencies=[Depends(verify_api_key)])
def remove_assignment(provider_id: int, service_id: int, db: Session = Depends(get_db)):
"""Rimuovi assegnazione servizio da operatore."""
ps = db.query(ProviderService).filter(
ProviderService.provider_id == provider_id,
ProviderService.service_id == service_id
).first()
if not ps:
raise HTTPException(404)
db.delete(ps)
db.commit()
return {"ok": True}
# === Calendar view ===
@router.get("/calendar", dependencies=[Depends(verify_api_key)])
def calendar_view(
from_date: date,
to_date: date,
provider_id: int | None = None,
db: Session = Depends(get_db),
):
"""Prenotazioni per vista calendario, raggruppate per provider e giorno."""
q = db.query(Booking).options(joinedload(Booking.service), joinedload(Booking.provider)).filter(
Booking.start_at >= datetime.combine(from_date, datetime.min.time(), tzinfo=TZ),
Booking.start_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time(), tzinfo=TZ),
Booking.status.in_(["confirmed", "completed"]),
)
if provider_id:
q = q.filter(Booking.provider_id == provider_id)
bookings = q.order_by(Booking.start_at).all()
# Group by date then provider
cal = {}
for b in bookings:
day = b.start_at.astimezone(TZ).strftime("%Y-%m-%d")
if day not in cal:
cal[day] = []
cal[day].append({
"id": b.id,
"start": b.start_at.astimezone(TZ).strftime("%H:%M"),
"end": b.end_at.astimezone(TZ).strftime("%H:%M"),
"service": b.service.name if b.service else "",
"service_slug": b.service.slug if b.service else "",
"category": b.service.category if b.service else "",
"provider_id": b.provider_id,
"provider": b.provider.name if b.provider else "",
"customer": b.customer_name,
"phone": b.customer_phone,
"status": b.status,
"notes": b.notes,
})
return cal
# === Stats ===
@router.get("/stats", dependencies=[Depends(verify_api_key)])
def booking_stats(db: Session = Depends(get_db)):
today_start = datetime.combine(datetime.now(TZ).date(), datetime.min.time(), tzinfo=TZ)
today_end = today_start + timedelta(days=1)
week_start = today_start - timedelta(days=today_start.weekday())
week_end = week_start + timedelta(days=7)
return {
"today": db.query(Booking).filter(Booking.start_at >= today_start, Booking.start_at < today_end, Booking.status == "confirmed").count(),
"this_week": db.query(Booking).filter(Booking.start_at >= week_start, Booking.start_at < week_end, Booking.status == "confirmed").count(),
"total_confirmed": db.query(Booking).filter(Booking.status == "confirmed").count(),
"total_no_shows": db.query(Booking).filter(Booking.status == "no_show").count(),
}

135
app/routers/auth.py Normal file
View File

@@ -0,0 +1,135 @@
"""Auth — dual mode: cookie (legacy) + localStorage token via URL param.
Flow: /auth/login → Google → /auth/callback → /admin?token=JWT
Il JS salva in localStorage, le API leggono da Authorization header O cookie.
"""
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, HTTPException, Header
from fastapi.responses import RedirectResponse, HTMLResponse
from jose import jwt
import httpx
from typing import Optional
from app.config import get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
COOKIE_NAME = "booking_token"
def create_jwt(email: str, name: str, picture: str = "") -> str:
settings = get_settings()
return jwt.encode({
"sub": email, "name": name, "picture": picture,
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours),
"iat": datetime.now(timezone.utc),
}, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def verify_jwt(token: str) -> dict | None:
settings = get_settings()
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except Exception:
return None
def get_current_user(request: Request) -> dict | None:
# 1. Check Authorization header (localStorage flow)
auth = request.headers.get("authorization", "")
if auth.startswith("Bearer "):
payload = verify_jwt(auth[7:])
if payload:
return payload
# 2. Fallback: check cookie
token = request.cookies.get(COOKIE_NAME)
if token:
return verify_jwt(token)
return None
@router.get("/login")
def login():
settings = get_settings()
params = {
"client_id": settings.google_client_id,
"redirect_uri": settings.google_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
"prompt": "select_account",
}
url = GOOGLE_AUTH_URL + "?" + "&".join(f"{k}={v}" for k, v in params.items())
return RedirectResponse(url)
@router.get("/dev")
def dev_login(key: str = ""):
"""Dev bypass."""
settings = get_settings()
if key != settings.api_key:
raise HTTPException(403, "Chiave non valida")
token = create_jwt("mancosu@kitzanos.com", "Carlo Mancosu")
logger.info("Dev login: mancosu@kitzanos.com")
return RedirectResponse(url=f"/admin?token={token}", status_code=302)
@router.get("/callback")
async def callback(code: str):
"""Callback da Google → redirect a /admin?token=JWT"""
settings = get_settings()
async with httpx.AsyncClient() as client:
token_resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
})
if token_resp.status_code != 200:
logger.error(f"Google token error: {token_resp.text}")
raise HTTPException(401, "Autenticazione Google fallita")
access_token = token_resp.json().get("access_token")
async with httpx.AsyncClient() as client:
user_resp = await client.get(GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"})
if user_resp.status_code != 200:
raise HTTPException(401, "Impossibile ottenere info utente")
user_info = user_resp.json()
email = user_info.get("email", "")
name = user_info.get("name", "")
picture = user_info.get("picture", "")
domain = email.split("@")[-1].lower() if "@" in email else ""
if domain not in settings.allowed_domains_list:
logger.warning(f"Accesso negato: {email}")
return RedirectResponse(url="/admin?error=domain", status_code=302)
token = create_jwt(email, name, picture)
logger.info(f"Login riuscito: {email}")
return RedirectResponse(url=f"/admin?token={token}", status_code=302)
@router.get("/me")
def me(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(401, "Non autenticato")
return {"email": user["sub"], "name": user.get("name", ""), "picture": user.get("picture", "")}
@router.get("/logout")
def logout():
resp = RedirectResponse(url="/admin?logout=1", status_code=302)
resp.delete_cookie(COOKIE_NAME, path="/")
return resp

161
app/routers/customers.py Normal file
View File

@@ -0,0 +1,161 @@
"""Endpoint clienti e dashboard avanzata."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import text, func
from app.database import get_db
from app.models import Booking, Service, Provider
from app.routers.admin import verify_api_key
router = APIRouter(prefix="/api/admin", tags=["customers"])
TZ = ZoneInfo("Europe/Rome")
# === Customers ===
@router.get("/customers", dependencies=[Depends(verify_api_key)])
def list_customers(q: str = "", page: int = 1, per_page: int = 20, db: Session = Depends(get_db)):
"""Lista clienti con ricerca e paginazione."""
where = ""
params = {}
if q:
where = " WHERE name ILIKE :q OR phone ILIKE :q OR email ILIKE :q"
params["q"] = f"%{q}%"
total = db.execute(text("SELECT COUNT(*) FROM customers" + where), params).scalar()
sql = "SELECT id, name, phone, email, notes, total_visits, last_visit_at, first_visit_at, created_at FROM customers" + where
sql += " ORDER BY total_visits DESC, last_visit_at DESC NULLS LAST"
sql += f" LIMIT {per_page} OFFSET {(page-1)*per_page}"
rows = db.execute(text(sql), params).fetchall()
return {"items": [dict(r._mapping) for r in rows], "total": total, "page": page, "per_page": per_page, "pages": max(1,(total + per_page - 1) // per_page)}
@router.get("/customers/{customer_id}", dependencies=[Depends(verify_api_key)])
def customer_detail(customer_id: int, db: Session = Depends(get_db)):
"""Dettaglio cliente con storico prenotazioni."""
c = db.execute(text("SELECT * FROM customers WHERE id = :id"), {"id": customer_id}).fetchone()
if not c:
raise HTTPException(404)
customer = dict(c._mapping)
# Storico prenotazioni
bookings = db.execute(text("""
SELECT b.id, b.start_at, b.end_at, b.status, b.notes,
s.name as service, p.name as provider
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
LEFT JOIN providers p ON p.id = b.provider_id
WHERE b.customer_id = :cid
ORDER BY b.start_at DESC
"""), {"cid": customer_id}).fetchall()
customer["bookings"] = [dict(r._mapping) for r in bookings]
# Servizi più usati
top_svcs = db.execute(text("""
SELECT s.name, COUNT(*) as cnt
FROM bookings b JOIN services s ON s.id = b.service_id
WHERE b.customer_id = :cid
GROUP BY s.name ORDER BY cnt DESC LIMIT 5
"""), {"cid": customer_id}).fetchall()
customer["top_services"] = [{"name": r[0], "count": r[1]} for r in top_svcs]
return customer
@router.put("/customers/{customer_id}", dependencies=[Depends(verify_api_key)])
def update_customer(customer_id: int, data: dict, db: Session = Depends(get_db)):
"""Aggiorna note cliente."""
db.execute(text("UPDATE customers SET notes = :notes WHERE id = :id"),
{"notes": data.get("notes", ""), "id": customer_id})
db.commit()
return {"ok": True}
# === Dashboard avanzata ===
@router.get("/dashboard", dependencies=[Depends(verify_api_key)])
def dashboard_data(db: Session = Depends(get_db)):
"""Dati completi per la dashboard."""
now = datetime.now(TZ)
today = now.date()
today_start = datetime.combine(today, datetime.min.time(), tzinfo=TZ)
next7_end = today_start + timedelta(days=7)
# KPI base
today_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'"
), {"s": today_start, "e": today_start + timedelta(days=1)}).scalar()
week_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')"
), {"s": today_start, "e": next7_end}).scalar()
# Domani
tomorrow_start = today_start + timedelta(days=1)
tomorrow_count = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status = 'confirmed'"
), {"s": tomorrow_start, "e": tomorrow_start + timedelta(days=1)}).scalar()
total = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','completed')")).scalar()
no_shows = db.execute(text("SELECT COUNT(*) FROM bookings WHERE status = 'no_show'")).scalar()
total_customers = db.execute(text("SELECT COUNT(*) FROM customers")).scalar()
# Prenotazioni per giorno questa settimana (per chart)
week_daily = []
for d in range(7):
day = today_start + timedelta(days=d)
day_end = day + timedelta(days=1)
cnt = db.execute(text(
"SELECT COUNT(*) FROM bookings WHERE start_at >= :s AND start_at < :e AND status IN ('confirmed','completed')"
), {"s": day, "e": day_end}).scalar()
weekday_idx = (today.weekday() + d) % 7
week_daily.append({"day": ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][weekday_idx], "date": day.strftime("%Y-%m-%d"), "count": cnt, "is_today": d == 0})
# Top servizi (ultimi 30gg)
top_services = db.execute(text("""
SELECT s.name, COUNT(*) as cnt FROM bookings b
JOIN services s ON s.id = b.service_id
WHERE b.start_at >= :s AND b.status IN ('confirmed','completed')
GROUP BY s.name ORDER BY cnt DESC LIMIT 5
"""), {"s": today_start - timedelta(days=30)}).fetchall()
# Top operatori (ultimi 30gg)
top_providers = db.execute(text("""
SELECT p.name, COUNT(*) as cnt FROM bookings b
JOIN providers p ON p.id = b.provider_id
WHERE b.start_at >= :s AND b.status IN ('confirmed','completed')
GROUP BY p.name ORDER BY cnt DESC LIMIT 5
"""), {"s": today_start - timedelta(days=30)}).fetchall()
# Prossime prenotazioni (prossimi 7 giorni)
upcoming = db.execute(text("""
SELECT b.id, b.start_at, b.customer_name, s.name as service, p.name as provider, b.status
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
LEFT JOIN providers p ON p.id = b.provider_id
WHERE b.start_at >= :now AND b.start_at < :week_end AND b.status = 'confirmed'
ORDER BY b.start_at LIMIT 8
"""), {"now": now, "week_end": today_start + timedelta(days=7)}).fetchall()
# Attività recenti (ultime 10 prenotazioni create)
recent = db.execute(text("""
SELECT b.id, b.created_at, b.customer_name, s.name as service, b.status
FROM bookings b LEFT JOIN services s ON s.id = b.service_id
ORDER BY b.created_at DESC LIMIT 8
""")).fetchall()
return {
"kpi": {
"today": today_count,
"week": week_count,
"tomorrow": tomorrow_count,
"total": total,
"no_shows": no_shows,
"customers": total_customers,
},
"week_chart": week_daily,
"top_services": [{"name": r[0], "count": r[1]} for r in top_services],
"top_providers": [{"name": r[0], "count": r[1]} for r in top_providers],
"upcoming": [dict(r._mapping) for r in upcoming],
"recent": [dict(r._mapping) for r in recent],
}

113
app/routers/public.py Normal file
View File

@@ -0,0 +1,113 @@
"""Endpoint pubblici — no auth. Usati dal widget di prenotazione."""
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import Service, Booking, ProviderService
from app.schemas import ServiceOut, BookingCreate, BookingOut, TimeSlot
from app.services.availability import get_available_slots
from app.services.notifications import notify_booking_confirmed, notify_operator
router = APIRouter(prefix="/api", tags=["public"])
TZ = ZoneInfo("Europe/Rome")
@router.get("/services", response_model=list[ServiceOut])
def list_services(db: Session = Depends(get_db)):
"""Lista servizi attivi."""
return (
db.query(Service)
.filter(Service.active == True)
.order_by(Service.sort_order, Service.name)
.all()
)
@router.get("/services/{service_id}/slots")
def get_slots(service_id: int, date: date, db: Session = Depends(get_db)):
"""Slot disponibili per un servizio in una data."""
service = db.query(Service).filter(Service.id == service_id).first()
if not service:
raise HTTPException(404, "Servizio non trovato")
slots = get_available_slots(db, service_id, date, service.duration_min)
return {"service": service.name, "date": str(date), "duration_min": service.duration_min, "slots": slots}
@router.post("/bookings", response_model=BookingOut)
async def create_booking(data: BookingCreate, db: Session = Depends(get_db)):
"""Crea una prenotazione. No login richiesto."""
# Verifica servizio
service = db.query(Service).filter(Service.id == data.service_id).first()
if not service:
raise HTTPException(404, "Servizio non trovato")
# Verifica che lo slot sia ancora libero
slots = get_available_slots(db, data.service_id, data.start_at.date(), service.duration_min)
slot_time = data.start_at.astimezone(TZ).strftime("%H:%M")
available = [s for s in slots if s.start == slot_time and s.provider_id == data.provider_id]
if not available:
raise HTTPException(409, "Lo slot non è più disponibile")
# Crea prenotazione
end_at = data.start_at + timedelta(minutes=service.duration_min)
booking = Booking(
service_id=data.service_id,
provider_id=data.provider_id,
customer_name=data.customer_name,
customer_phone=data.customer_phone,
customer_email=data.customer_email,
start_at=data.start_at,
end_at=end_at,
notes=data.notes,
)
db.add(booking)
db.commit()
db.refresh(booking)
# Carica relazioni per la risposta
booking = db.query(Booking).options(
joinedload(Booking.service), joinedload(Booking.provider)
).filter(Booking.id == booking.id).first()
# Notifiche async
await notify_booking_confirmed(booking, service.name, booking.provider.name)
if booking.provider.email:
await notify_operator(booking, service.name, booking.provider.email)
return booking
@router.get("/bookings/my")
def my_bookings(phone: str, db: Session = Depends(get_db)):
"""Le prenotazioni di un numero di telefono (per check 'ho già prenotato?')."""
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
bookings = (
db.query(Booking)
.options(joinedload(Booking.service), joinedload(Booking.provider))
.filter(
Booking.customer_phone.contains(clean),
Booking.status == "confirmed",
Booking.start_at >= datetime.now(TZ),
)
.order_by(Booking.start_at)
.all()
)
return bookings
@router.delete("/bookings/{booking_id}")
def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)):
"""Cancella una prenotazione (verifica phone per sicurezza)."""
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(404, "Prenotazione non trovata")
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
if clean not in booking.customer_phone:
raise HTTPException(403, "Numero non corrispondente")
booking.status = "cancelled"
db.commit()
return {"ok": True, "message": "Prenotazione cancellata"}

85
app/routers/settings.py Normal file
View File

@@ -0,0 +1,85 @@
"""Endpoint impostazioni — protetti con API key."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.database import get_db
from app.routers.admin import verify_api_key
router = APIRouter(prefix="/api/admin/settings", tags=["settings"], dependencies=[Depends(verify_api_key)])
@router.get("")
def get_settings(db: Session = Depends(get_db)):
"""Tutte le impostazioni come dict."""
rows = db.execute(text("SELECT key, value FROM settings ORDER BY key")).fetchall()
return {r[0]: r[1] for r in rows}
@router.put("")
def update_settings(data: dict, db: Session = Depends(get_db)):
"""Aggiorna una o più impostazioni. Body: {"key": "value", ...}"""
for k, v in data.items():
db.execute(
text("INSERT INTO settings (key, value, updated_at) VALUES (:k, :v, NOW()) "
"ON CONFLICT (key) DO UPDATE SET value = :v, updated_at = NOW()"),
{"k": k, "v": str(v)}
)
db.commit()
return get_settings(db)
@router.post("/test-email")
async def test_email(db: Session = Depends(get_db)):
"""Invia email di test con le impostazioni correnti."""
rows = db.execute(text("SELECT key, value FROM settings WHERE key LIKE 'smtp_%'")).fetchall()
cfg = {r[0]: r[1] for r in rows}
if not cfg.get("smtp_user"):
raise HTTPException(400, "Credenziali SMTP non configurate")
import smtplib
from email.mime.text import MIMEText
try:
msg = MIMEText("<h2>Test notifiche Farmacia Ianni</h2><p>Le impostazioni email funzionano correttamente.</p>", "html")
msg["Subject"] = "Test — Booking Farmacia Ianni"
msg["From"] = cfg.get("smtp_from", cfg["smtp_user"])
msg["To"] = cfg["smtp_user"]
with smtplib.SMTP(cfg.get("smtp_host", "smtp.gmail.com"), int(cfg.get("smtp_port", 587))) as server:
server.starttls()
server.login(cfg["smtp_user"], cfg.get("smtp_pass", ""))
server.sendmail(msg["From"], cfg["smtp_user"], msg.as_string())
return {"ok": True, "message": f"Email di test inviata a {cfg['smtp_user']}"}
except Exception as e:
raise HTTPException(500, f"Errore invio: {str(e)}")
@router.get("/wa-status")
async def wa_status(db: Session = Depends(get_db)):
"""Stato connessione WhatsApp Baileys."""
import httpx
rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone()
url = rows[0] if rows else "http://booking-wa:3100"
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{url}/status")
return r.json()
except Exception as e:
return {"connected": False, "error": str(e), "message": "Servizio WhatsApp non raggiungibile"}
@router.get("/wa-qr")
async def wa_qr(db: Session = Depends(get_db)):
"""QR code per collegare WhatsApp."""
import httpx
rows = db.execute(text("SELECT value FROM settings WHERE key = 'wa_service_url'")).fetchone()
url = rows[0] if rows else "http://booking-wa:3100"
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{url}/qr")
return r.json()
except Exception as e:
return {"qr": None, "error": str(e)}