v1.3.0 — pannello admin completo, auth localStorage, Baileys WA, customers, calendario, paginazione, dashboard 7gg
This commit is contained in:
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)}
|
||||
Reference in New Issue
Block a user