v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
52
app/config.py
Normal file
52
app/config.py
Normal 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
19
app/database.py
Normal 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
55
app/main.py
Normal 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
71
app/models.py
Normal 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
0
app/routers/__init__.py
Normal file
258
app/routers/admin.py
Normal file
258
app/routers/admin.py
Normal 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
135
app/routers/auth.py
Normal 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
161
app/routers/customers.py
Normal 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
113
app/routers/public.py
Normal 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
85
app/routers/settings.py
Normal 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
90
app/schemas.py
Normal 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
0
app/services/__init__.py
Normal file
100
app/services/availability.py
Normal file
100
app/services/availability.py
Normal 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
|
||||
114
app/services/notifications.py
Normal file
114
app/services/notifications.py
Normal 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)
|
||||
Reference in New Issue
Block a user