new init
This commit is contained in:
361
apps/backend/app/api/stats.py
Normal file
361
apps/backend/app/api/stats.py
Normal file
@@ -0,0 +1,361 @@
|
||||
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()]
|
Reference in New Issue
Block a user