Files
booking-service/app/routers/public.py
ECO a3f1d3291a v1.4.0 — 16 servizi reali Ianni, sala unica, ricevuta PDF
- Migration: services.price_cents, services.availability_text
- Migration: bookings.receipt_number (trigger annuale IANNI-YYYY-NNNN) + receipt_token
- Constraint EXCLUDE bookings_no_overlap (sala unica, status confirmed/completed)
- availability.py: calcolo slot globale (non più per-provider)
- 16 servizi reali da Servizi.xls inseriti, 9 demo archiviati con FK preservata
- provider_services: 3 profili orari (lun-sab 9-19, lun-mar 9-13, lun-gio 9-13)
- Endpoint GET /api/receipts/{token} → PDF WeasyPrint
- Template HTML ricevuta con palette Ianni
- Dockerfile: deps sistema weasyprint (pango/cairo/fonts)
- requirements: +weasyprint>=63.0
- Frontend index.html: prezzo + fascia oraria nelle card servizio, link Scarica ricevuta nella conferma
2026-05-14 12:31:45 +00:00

146 lines
5.5 KiB
Python

"""Endpoint pubblici — no auth. Usati dal widget di prenotazione."""
import secrets
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
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
from fastapi.responses import Response
from app.services.receipt import render_receipt_pdf
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,
)
booking.receipt_token = secrets.token_urlsafe(24)
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"}
@router.get("/receipts/{token}")
def get_receipt(token: str, db: Session = Depends(get_db)):
"""Restituisce il PDF della ricevuta. Token random anti-enumeration."""
booking = (
db.query(Booking)
.options(joinedload(Booking.service), joinedload(Booking.provider))
.filter(Booking.receipt_token == token)
.first()
)
if not booking:
raise HTTPException(404, "Ricevuta non trovata")
if booking.status not in ("confirmed", "completed"):
raise HTTPException(410, "Ricevuta non disponibile per questa prenotazione")
# Settings → dict
settings_rows = db.execute(text("SELECT key, value FROM settings")).fetchall()
settings_map = {k: v for k, v in settings_rows}
pdf_bytes = render_receipt_pdf(booking, booking.service, booking.provider, settings_map)
fname = f"ricevuta-{booking.receipt_number or booking.id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{fname}"'},
)