362 lines
12 KiB
Python
362 lines
12 KiB
Python
from datetime import datetime, timedelta, timezone
|
||
from typing import Optional, List, Dict, Any, Tuple
|
||
|
||
from fastapi import APIRouter, Depends, Query
|
||
from sqlalchemy import func, and_
|
||
from sqlalchemy.orm import Session
|
||
|
||
# TZ: ZoneInfo ist vorhanden, aber es fehlen auf Windows oft die IANA-Daten.
|
||
try:
|
||
from zoneinfo import ZoneInfo
|
||
except Exception: # sehr defensive Fallbacks
|
||
ZoneInfo = None # type: ignore
|
||
|
||
from app.core.database import get_db
|
||
from app.core.auth import get_current_user, requires_role
|
||
from app.models.booking import Booking
|
||
from app.models.user import User
|
||
from app.models.product import Product
|
||
from app.models.delivery import Delivery # hat KEIN created_at bei dir
|
||
|
||
|
||
router = APIRouter(prefix="/stats", tags=["stats"])
|
||
|
||
|
||
# ----------------- Helpers -----------------
|
||
|
||
def _get_tz(tz_name: str) -> timezone:
|
||
"""
|
||
Liefert eine tzinfo. Auf Windows ohne tzdata fällt das auf UTC zurück,
|
||
statt eine ZoneInfoNotFoundError zu werfen.
|
||
"""
|
||
if ZoneInfo is not None:
|
||
try:
|
||
return ZoneInfo(tz_name) # type: ignore
|
||
except Exception:
|
||
pass
|
||
# Fallback: UTC – korrekt, aber ohne CET/CEST-Offset.
|
||
return timezone.utc
|
||
|
||
|
||
def _to_naive_utc(dt: Optional[datetime]) -> Optional[datetime]:
|
||
"""
|
||
Konvertiert timezone-aware nach UTC und entfernt tzinfo.
|
||
Bei None -> None. Bei nativ-naiv wird unverändert zurückgegeben.
|
||
"""
|
||
if dt is None:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
return dt
|
||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||
|
||
|
||
def _delivery_time_column():
|
||
"""
|
||
Ermittelt die Zeitspalte des Delivery-Modells dynamisch.
|
||
Reihenfolge: created_at | timestamp | created | delivered_at
|
||
Gibt (Column, name) oder (None, None) zurück.
|
||
"""
|
||
for name in ("created_at", "timestamp", "created", "delivered_at"):
|
||
col = getattr(Delivery, name, None)
|
||
if col is not None:
|
||
return col, name
|
||
return None, None
|
||
|
||
|
||
def _last_delivery_at(db: Session) -> Optional[datetime]:
|
||
"""
|
||
Bestimmt den Zeitpunkt der letzten Lieferung anhand der vorhandenen Spalte.
|
||
Achtung: Gibt das zurück, was in der DB liegt (aware oder naiv).
|
||
"""
|
||
col, _ = _delivery_time_column()
|
||
if col is None:
|
||
return None
|
||
return db.query(func.max(col)).scalar()
|
||
|
||
|
||
def _period_bounds(db: Session, period: str, tz_name: str = "Europe/Berlin") -> Tuple[Optional[datetime], Optional[datetime], Optional[datetime]]:
|
||
"""
|
||
Liefert (from_utc_naive, to_utc_naive, last_delivery_time_original).
|
||
Booking.timestamp ist in deiner App naiv/UTC, daher filtern wir mit naiven UTC-Grenzen.
|
||
"""
|
||
tz = _get_tz(tz_name)
|
||
now_local = datetime.now(tz)
|
||
now_utc_naive = now_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||
|
||
last_delivery = _last_delivery_at(db)
|
||
|
||
if period == "last_delivery":
|
||
if last_delivery is None:
|
||
# Fallback: 30 Tage rückwärts
|
||
from_utc_naive = (now_local - timedelta(days=30)).astimezone(timezone.utc).replace(tzinfo=None)
|
||
else:
|
||
from_utc_naive = _to_naive_utc(last_delivery)
|
||
return (from_utc_naive, now_utc_naive, last_delivery)
|
||
|
||
if period == "ytd":
|
||
start_local = datetime(now_local.year, 1, 1, tzinfo=tz)
|
||
start_utc_naive = start_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return (start_utc_naive, now_utc_naive, last_delivery)
|
||
|
||
# "all" -> keine untere Grenze
|
||
return (None, now_utc_naive, last_delivery)
|
||
|
||
|
||
# ----------------- Öffentliche Stat-Endpunkte (auth-pflichtig, aber nicht nur Admin) -----------------
|
||
|
||
@router.get("/meta")
|
||
def stats_meta(
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
tz: str = Query("Europe/Berlin")
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Metadaten für die Stats-Seite:
|
||
- categories: distinct Product.category (ohne NULL)
|
||
- last_delivery_at: ISO-Zeitpunkt der letzten Lieferung (falls vorhanden)
|
||
- visible_count: Nutzer mit public_stats==true & alias gesetzt & is_active==true
|
||
- hidden_count: aktive Nutzer minus visible_count
|
||
- now: Serverzeit in tz
|
||
"""
|
||
# Kategorien
|
||
cats = [
|
||
c for (c,) in db.query(Product.category)
|
||
.filter(Product.category.isnot(None))
|
||
.distinct()
|
||
.order_by(Product.category.asc())
|
||
.all()
|
||
]
|
||
|
||
# Letzte Lieferung – robust gegen fehlende Spalte
|
||
last_delivery_time = _last_delivery_at(db)
|
||
|
||
# Sichtbarkeit
|
||
visible_count = (
|
||
db.query(func.count(User.id))
|
||
.filter(
|
||
and_(
|
||
User.is_active.is_(True),
|
||
User.alias.isnot(None),
|
||
func.length(func.trim(User.alias)) > 0,
|
||
getattr(User, "public_stats", False) == True, # robust falls Migration noch nicht durch
|
||
)
|
||
)
|
||
.scalar()
|
||
or 0
|
||
)
|
||
active_count = db.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
|
||
|
||
now_local = datetime.now(_get_tz(tz))
|
||
return {
|
||
"categories": cats,
|
||
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
|
||
"visible_count": int(visible_count),
|
||
"hidden_count": int(max(0, active_count - visible_count)),
|
||
"now": now_local.isoformat(),
|
||
}
|
||
|
||
|
||
@router.get("/top-drinkers")
|
||
def top_drinkers(
|
||
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
|
||
category: str = Query("all"),
|
||
limit: int = Query(5, ge=1, le=50),
|
||
tz: str = Query("Europe/Berlin"),
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Top-Trinker je Zeitraum und optional Kategorie.
|
||
Aggregation: Summe Booking.amount (Stückzahl), zusätzlich count(*) und SUM(total_cents).
|
||
Nur Nutzer mit Opt-in (public_stats) + Alias + aktiv.
|
||
"""
|
||
from_dt, to_dt, _ = _period_bounds(db, period, tz)
|
||
|
||
q = (
|
||
db.query(
|
||
User.id.label("user_id"),
|
||
User.alias.label("alias"),
|
||
User.avatar_url.label("avatar_url"),
|
||
func.coalesce(func.sum(Booking.amount), 0).label("amount_sum"),
|
||
func.count(Booking.id).label("count"),
|
||
func.coalesce(func.sum(Booking.total_cents), 0).label("revenue_cents"),
|
||
)
|
||
.join(User, User.id == Booking.user_id)
|
||
)
|
||
|
||
if category != "all":
|
||
q = q.join(Product, Product.id == Booking.product_id).filter(Product.category == category)
|
||
|
||
if from_dt is not None:
|
||
q = q.filter(Booking.timestamp >= from_dt)
|
||
if to_dt is not None:
|
||
q = q.filter(Booking.timestamp <= to_dt)
|
||
|
||
# Sichtbarkeitsregeln – robust falls Spalte noch nicht migriert:
|
||
public_stats_col = getattr(User, "public_stats", None)
|
||
conds = [
|
||
User.is_active.is_(True),
|
||
User.alias.isnot(None),
|
||
func.length(func.trim(User.alias)) > 0,
|
||
]
|
||
if public_stats_col is not None:
|
||
conds.append(public_stats_col.is_(True))
|
||
|
||
q = q.filter(and_(*conds))
|
||
|
||
q = q.group_by(User.id, User.alias, User.avatar_url).order_by(func.coalesce(func.sum(Booking.amount), 0).desc()).limit(limit)
|
||
rows = q.all()
|
||
|
||
return [
|
||
{
|
||
"user_id": r.user_id,
|
||
"alias": r.alias,
|
||
"avatar_url": r.avatar_url,
|
||
"amount_sum": int(r.amount_sum or 0),
|
||
"count": int(r.count or 0),
|
||
"revenue_cents": int(r.revenue_cents or 0),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
@router.get("/product-share")
|
||
def product_share(
|
||
period: str = Query("last_delivery", pattern="^(last_delivery|ytd|all)$"),
|
||
tz: str = Query("Europe/Berlin"),
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Produktverteilung (Kreisdiagramm): COUNT(Booking.id) je Produkt im Zeitraum.
|
||
Unabhängig vom Opt-in, da keine personenbezogenen Daten.
|
||
"""
|
||
from_dt, to_dt, _ = _period_bounds(db, period, tz)
|
||
|
||
q = (
|
||
db.query(
|
||
Product.id.label("product_id"),
|
||
Product.name.label("product_name"),
|
||
func.count(Booking.id).label("count_rows"),
|
||
)
|
||
.join(Product, Product.id == Booking.product_id)
|
||
)
|
||
|
||
if from_dt is not None:
|
||
q = q.filter(Booking.timestamp >= from_dt)
|
||
if to_dt is not None:
|
||
q = q.filter(Booking.timestamp <= to_dt)
|
||
|
||
q = q.group_by(Product.id, Product.name).order_by(func.count(Booking.id).desc())
|
||
items = q.all()
|
||
|
||
total = sum(int(r.count_rows or 0) for r in items)
|
||
return {
|
||
"total": int(total),
|
||
"items": [
|
||
{
|
||
"product_id": r.product_id,
|
||
"product_name": r.product_name,
|
||
"count": int(r.count_rows or 0),
|
||
}
|
||
for r in items
|
||
],
|
||
}
|
||
|
||
|
||
# ----------------- Bestehende Admin-Stats (weiterhin verfügbar) -----------------
|
||
|
||
@router.get("/summary", dependencies=[Depends(requires_role("manager", "admin"))])
|
||
def summary(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
users = db.query(func.count(User.id)).scalar() or 0
|
||
products = db.query(func.count(Product.id)).scalar() or 0
|
||
bookings = db.query(func.count(Booking.id)).scalar() or 0
|
||
total_revenue = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
|
||
|
||
return {
|
||
"num_users": users,
|
||
"num_products": products,
|
||
"num_bookings": bookings,
|
||
"total_revenue_cents": int(total_revenue),
|
||
"my_balance_cents": int(user.balance_cents or 0),
|
||
"stock_overview": None,
|
||
}
|
||
|
||
|
||
@router.get("/revenue-summary", dependencies=[Depends(requires_role("manager", "admin"))])
|
||
def revenue_summary(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||
total_cents = db.query(func.coalesce(func.sum(Booking.total_cents), 0)).scalar() or 0
|
||
|
||
# dynamische Delivery-Zeitspalte verwenden
|
||
last_delivery_time = _last_delivery_at(db)
|
||
|
||
if last_delivery_time:
|
||
since_last_cents = (
|
||
db.query(func.coalesce(func.sum(Booking.total_cents), 0))
|
||
.filter(Booking.timestamp > _to_naive_utc(last_delivery_time))
|
||
.scalar()
|
||
or 0
|
||
)
|
||
else:
|
||
since_last_cents = total_cents
|
||
|
||
return {
|
||
"revenue_total_cents": int(total_cents),
|
||
"revenue_since_last_delivery_cents": int(since_last_cents),
|
||
"last_delivery_at": last_delivery_time.isoformat() if last_delivery_time else None,
|
||
}
|
||
|
||
|
||
# (optionale Alt-Endpunkte, falls noch genutzt)
|
||
@router.get("/consumption-per-user", dependencies=[Depends(requires_role("manager", "admin"))])
|
||
def consumption_per_user(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||
rows = (
|
||
db.query(
|
||
User.id.label("user_id"),
|
||
User.name,
|
||
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
|
||
)
|
||
.outerjoin(Booking, Booking.user_id == User.id)
|
||
.group_by(User.id, User.name)
|
||
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
|
||
.all()
|
||
)
|
||
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in rows]
|
||
|
||
|
||
@router.get("/consumption-per-product", dependencies=[Depends(requires_role("manager", "admin"))])
|
||
def consumption_per_product(db: Session = Depends(get_db), _: User = Depends(get_current_user)):
|
||
rows = (
|
||
db.query(
|
||
Product.id.label("product_id"),
|
||
Product.name.label("name"),
|
||
func.count(Booking.id).label("count"),
|
||
)
|
||
.outerjoin(Booking, Booking.product_id == Product.id)
|
||
.group_by(Product.id, Product.name)
|
||
.order_by(func.count(Booking.id).desc())
|
||
.all()
|
||
)
|
||
return [{"product_id": r.product_id, "name": r.name, "count": int(r.count or 0)} for r in rows]
|
||
|
||
|
||
@router.get("/monthly-ranking", dependencies=[Depends(requires_role("manager", "admin"))])
|
||
def monthly_ranking(
|
||
year: Optional[int] = Query(None),
|
||
month: Optional[int] = Query(None),
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
):
|
||
q = (
|
||
db.query(
|
||
User.id.label("user_id"),
|
||
User.name,
|
||
func.coalesce(func.sum(Booking.total_cents), 0).label("total_cents"),
|
||
)
|
||
.join(User, User.id == Booking.user_id)
|
||
.group_by(User.id, User.name)
|
||
.order_by(func.coalesce(func.sum(Booking.total_cents), 0).desc())
|
||
.limit(10)
|
||
)
|
||
return [{"user_id": r.user_id, "name": r.name, "total_cents": int(r.total_cents or 0)} for r in q.all()]
|