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/__init__.py Normal file
View File

52
app/config.py Normal file
View File

@@ -0,0 +1,52 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql://booking:booking2026@db:5432/booking"
# App
app_name: str = "BookingService"
app_url: str = "https://booking.scan360.app"
api_key: str = "changeme"
cors_origins: list[str] = ["https://farmaciaianni.it", "http://localhost:3000"]
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "https://booking.scan360.app/auth/callback"
allowed_domains: str = "kitzanos.com,farmaciaianni.it"
# JWT
jwt_secret: str = "xab-booking-jwt-secret-2026"
jwt_algorithm: str = "HS256"
jwt_expire_hours: int = 24
# Google Calendar
google_credentials_json: str = ""
# Notifications
smtp_host: str = "smtp.gmail.com"
smtp_port: int = 587
smtp_user: str = ""
smtp_pass: str = ""
smtp_from: str = "prenotazioni@farmaciaianni.it"
# WhatsApp gateway
wa_gateway_url: str = "http://host.docker.internal:18800/api/wa/send"
wa_enabled: bool = True
# Timezone
timezone: str = "Europe/Rome"
model_config = {"env_prefix": "BOOKING_", "env_file": ".env", "extra": "ignore"}
@property
def allowed_domains_list(self) -> list[str]:
return [d.strip() for d in self.allowed_domains.split(",") if d.strip()]
@lru_cache()
def get_settings() -> Settings:
return Settings()

19
app/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

55
app/main.py Normal file
View File

@@ -0,0 +1,55 @@
"""BookingService — microservizio prenotazioni FastAPI.
Container Docker autonomo, DB dedicato, zero dipendenze dal resto.
Google OAuth2 per admin con vincolo dominio.
"""
import logging
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.config import get_settings
from app.routers import public, admin, auth, customers
from app.routers import settings as settings_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version="1.3.0",
docs_url="/api/docs",
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(public.router)
app.include_router(admin.router)
app.include_router(customers.router)
app.include_router(settings_router.router)
@app.get("/api/health")
def health():
return {"status": "ok", "service": settings.app_name, "version": "1.3.0"}
FRONTEND_DIR = Path("/app/frontend")
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@app.get("/")
def serve_index():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/admin")
def serve_admin():
return FileResponse(str(FRONTEND_DIR / "admin.html"))

71
app/models.py Normal file
View File

@@ -0,0 +1,71 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True)
name = Column(Text, nullable=False)
slug = Column(Text, nullable=False, unique=True)
duration_min = Column(Integer, nullable=False, default=15)
description = Column(Text)
category = Column(Text, default="generale")
active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
provider_services = relationship("ProviderService", back_populates="service")
bookings = relationship("Booking", back_populates="service")
class Provider(Base):
__tablename__ = "providers"
id = Column(Integer, primary_key=True)
name = Column(Text, nullable=False)
email = Column(Text)
phone = Column(Text)
google_calendar_id = Column(Text)
active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
provider_services = relationship("ProviderService", back_populates="provider")
bookings = relationship("Booking", back_populates="provider")
class ProviderService(Base):
__tablename__ = "provider_services"
__table_args__ = (UniqueConstraint("provider_id", "service_id"),)
id = Column(Integer, primary_key=True)
provider_id = Column(Integer, ForeignKey("providers.id", ondelete="CASCADE"))
service_id = Column(Integer, ForeignKey("services.id", ondelete="CASCADE"))
availability_rules = Column(JSONB, nullable=False, default=[])
provider = relationship("Provider", back_populates="provider_services")
service = relationship("Service", back_populates="provider_services")
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True)
service_id = Column(Integer, ForeignKey("services.id"))
provider_id = Column(Integer, ForeignKey("providers.id"))
customer_name = Column(Text, nullable=False)
customer_phone = Column(Text, nullable=False)
customer_email = Column(Text)
start_at = Column(DateTime(timezone=True), nullable=False)
end_at = Column(DateTime(timezone=True), nullable=False)
status = Column(Text, default="confirmed")
google_event_id = Column(Text)
notes = Column(Text)
reminder_sent = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
service = relationship("Service", back_populates="bookings")
provider = relationship("Provider", back_populates="bookings")

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)}

90
app/schemas.py Normal file
View File

@@ -0,0 +1,90 @@
from pydantic import BaseModel, Field
from datetime import datetime, date
from typing import Optional
# === Services ===
class ServiceOut(BaseModel):
id: int
name: str
slug: str
duration_min: int
description: Optional[str] = None
category: str
class Config:
from_attributes = True
class ServiceCreate(BaseModel):
name: str
slug: str
duration_min: int = 15
description: Optional[str] = None
category: str = "generale"
# === Providers ===
class ProviderOut(BaseModel):
id: int
name: str
class Config:
from_attributes = True
class ProviderCreate(BaseModel):
name: str
email: Optional[str] = None
phone: Optional[str] = None
google_calendar_id: Optional[str] = None
# === Availability ===
class TimeSlot(BaseModel):
start: str # "09:00"
end: str # "09:30"
provider_id: int
provider_name: str
class AvailabilityRule(BaseModel):
weekday: int # 0=lun, 6=dom
start: str # "09:00"
end: str # "13:00"
# === Bookings ===
class BookingCreate(BaseModel):
service_id: int
provider_id: int
start_at: datetime
customer_name: str
customer_phone: str
customer_email: Optional[str] = None
notes: Optional[str] = None
class BookingOut(BaseModel):
id: int
service_id: int
provider_id: int
customer_name: str
customer_phone: str
customer_email: Optional[str] = None
start_at: datetime
end_at: datetime
status: str
notes: Optional[str] = None
created_at: datetime
service: Optional[ServiceOut] = None
provider: Optional[ProviderOut] = None
class Config:
from_attributes = True
class BookingUpdate(BaseModel):
status: Optional[str] = None
notes: Optional[str] = None

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

View File

@@ -0,0 +1,100 @@
"""Calcolo slot disponibili per un servizio in una data.
Logica: regole orario provider - prenotazioni esistenti - busy Google Calendar.
Google Calendar è opzionale (fase 2).
"""
from datetime import datetime, date, timedelta, time
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.models import ProviderService, Booking
from app.schemas import TimeSlot
TZ = ZoneInfo("Europe/Rome")
def get_available_slots(
db: Session,
service_id: int,
target_date: date,
duration_min: int,
) -> list[TimeSlot]:
"""Restituisce tutti gli slot liberi per un servizio in una data."""
# 1. Trova tutti i provider che erogano questo servizio
ps_list = (
db.query(ProviderService)
.filter(ProviderService.service_id == service_id)
.all()
)
if not ps_list:
return []
weekday = target_date.weekday() # 0=lunedì
all_slots = []
for ps in ps_list:
if not ps.provider.active:
continue
# 2. Filtra le regole di disponibilità per il giorno della settimana
rules = ps.availability_rules or []
day_rules = [r for r in rules if r.get("weekday") == weekday]
if not day_rules:
continue
# 3. Genera tutti gli slot possibili dalle regole
raw_slots = []
for rule in day_rules:
start_h, start_m = map(int, rule["start"].split(":"))
end_h, end_m = map(int, rule["end"].split(":"))
slot_start = datetime.combine(target_date, time(start_h, start_m), tzinfo=TZ)
slot_end_limit = datetime.combine(target_date, time(end_h, end_m), tzinfo=TZ)
while slot_start + timedelta(minutes=duration_min) <= slot_end_limit:
raw_slots.append(slot_start)
slot_start += timedelta(minutes=duration_min)
# 4. Filtra via le prenotazioni esistenti (confirmed)
day_start = datetime.combine(target_date, time(0, 0), tzinfo=TZ)
day_end = day_start + timedelta(days=1)
existing = (
db.query(Booking)
.filter(
Booking.provider_id == ps.provider_id,
Booking.start_at >= day_start,
Booking.start_at < day_end,
Booking.status == "confirmed",
)
.all()
)
busy_ranges = [(b.start_at, b.end_at) for b in existing]
for slot_start in raw_slots:
slot_end = slot_start + timedelta(minutes=duration_min)
conflict = any(
slot_start < busy_end and slot_end > busy_start
for busy_start, busy_end in busy_ranges
)
if not conflict:
all_slots.append(
TimeSlot(
start=slot_start.strftime("%H:%M"),
end=slot_end.strftime("%H:%M"),
provider_id=ps.provider_id,
provider_name=ps.provider.name,
)
)
# 5. Ordina per orario
all_slots.sort(key=lambda s: s.start)
# 6. Filtra slot nel passato se target_date è oggi
now = datetime.now(TZ)
if target_date == now.date():
all_slots = [s for s in all_slots if s.start > now.strftime("%H:%M")]
return all_slots

View File

@@ -0,0 +1,114 @@
"""Notifiche: email SMTP + WhatsApp via gateway XAB."""
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
import httpx
from app.config import get_settings
log = logging.getLogger(__name__)
settings = get_settings()
async def send_wa_message(phone: str, text: str) -> bool:
"""Invia messaggio WhatsApp via gateway XAB su APP:18800."""
if not settings.wa_enabled:
log.info(f"WA disabled, skip: {phone}")
return False
try:
# Normalizza numero: +39 3xx -> 39 3xx
clean = phone.replace("+", "").replace(" ", "").replace("-", "")
if not clean.startswith("39"):
clean = "39" + clean
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
settings.wa_gateway_url,
json={"to": clean, "text": text},
)
if r.status_code == 200:
log.info(f"WA sent to {clean}")
return True
else:
log.error(f"WA error {r.status_code}: {r.text}")
return False
except Exception as e:
log.error(f"WA exception: {e}")
return False
def send_email(to: str, subject: str, html_body: str) -> bool:
"""Invia email via SMTP."""
if not settings.smtp_user:
log.info(f"SMTP not configured, skip: {to}")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_user, settings.smtp_pass)
server.sendmail(settings.smtp_from, to, msg.as_string())
log.info(f"Email sent to {to}")
return True
except Exception as e:
log.error(f"Email exception: {e}")
return False
async def notify_booking_confirmed(booking, service_name: str, provider_name: str):
"""Notifica al cliente: conferma prenotazione."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
# WhatsApp al cliente
wa_text = (
f"✅ Prenotazione confermata!\n\n"
f"Servizio: {service_name}\n"
f"Data: {dt}\n"
f"Operatore: {provider_name}\n\n"
f"Farmacia Ianni - Via Cassia 940, Roma\n"
f"Per cancellare, rispondi CANCELLA."
)
await send_wa_message(booking.customer_phone, wa_text)
# Email se fornita
if booking.customer_email:
html = f"""
<h2>Prenotazione confermata</h2>
<p><strong>Servizio:</strong> {service_name}</p>
<p><strong>Data:</strong> {dt}</p>
<p><strong>Operatore:</strong> {provider_name}</p>
<hr>
<p>Farmacia Ianni - Via Cassia 940, Roma</p>
"""
send_email(booking.customer_email, f"Prenotazione {service_name} - {dt}", html)
async def notify_operator(booking, service_name: str, provider_email: str):
"""Notifica all'operatore: nuova prenotazione."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
html = f"""
<h2>Nuova prenotazione</h2>
<p><strong>Cliente:</strong> {booking.customer_name}</p>
<p><strong>Telefono:</strong> {booking.customer_phone}</p>
<p><strong>Servizio:</strong> {service_name}</p>
<p><strong>Data:</strong> {dt}</p>
"""
if provider_email:
send_email(provider_email, f"Nuova prenotazione: {booking.customer_name} - {dt}", html)
async def send_reminder(booking, service_name: str):
"""Reminder 24h prima via WhatsApp."""
dt = booking.start_at.strftime("%d/%m/%Y alle %H:%M")
text = (
f"📅 Promemoria: domani hai un appuntamento\n\n"
f"Servizio: {service_name}\n"
f"Data: {dt}\n\n"
f"Farmacia Ianni - Via Cassia 940, Roma"
)
await send_wa_message(booking.customer_phone, text)