- 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
146 lines
5.5 KiB
Python
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}"'},
|
|
)
|