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
This commit is contained in:
ECO
2026-05-14 12:31:45 +00:00
parent c33ec8450e
commit a3f1d3291a
9 changed files with 260 additions and 69 deletions

View File

@@ -1,13 +1,17 @@
"""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")
@@ -62,6 +66,7 @@ async def create_booking(data: BookingCreate, db: Session = Depends(get_db)):
end_at=end_at,
notes=data.notes,
)
booking.receipt_token = secrets.token_urlsafe(24)
db.add(booking)
db.commit()
db.refresh(booking)
@@ -111,3 +116,30 @@ def cancel_booking(booking_id: int, phone: str, db: Session = Depends(get_db)):
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}"'},
)