"""Admin router — JWT-protected operational endpoints. GET /api/admin/dashboard GET /api/admin/orders POST /api/admin/orders/{id}/status POST /api/admin/import GET /api/admin/price-rules POST /api/admin/recalc (recalc_prices task) POST /api/admin/sync-inventory (sync_inventory task) """ from datetime import datetime, time, timezone from decimal import Decimal from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.models import AuditLog, Order, PriceRule, Product, Supplier from app.routers.auth import require_admin from app.schemas import ImportRequest, OrderOut router = APIRouter(dependencies=[Depends(require_admin)]) ORDER_STATUSES = {"new", "paid", "fraud_check", "supplier_order", "confirmed", "shipped", "delivered", "cancelled", "refunded"} class StatusUpdate(BaseModel): status: str def _f(value) -> float: try: return float(value or 0) except (TypeError, ValueError): return 0.0 @router.get("/dashboard") def dashboard(db: Session = Depends(get_db)): today_start = datetime.combine(datetime.now(timezone.utc).date(), time.min) excluded = ["cancelled", "refunded"] def kpis(query): orders = query.all() revenue = sum(_f(o.retail_total) for o in orders) profit = sum(_f(o.profit) for o in orders) count = len(orders) return { "orders": count, "revenue": round(revenue, 2), "profit": round(profit, 2), "gross_profit": round(revenue - profit, 2), "margin": round(profit / revenue * 100, 2) if revenue else 0.0, "aov": round(revenue / count, 2) if count else 0.0, "refunds": sum(1 for o in orders if o.status == "refunded"), } today_q = db.query(Order).filter( Order.created_at >= today_start, ~Order.status.in_(excluded) ) all_q = db.query(Order).filter(~Order.status.in_(excluded)) return { "today": kpis(today_q), "all_time": kpis(all_q), "products_total": db.query(Product).count(), } @router.get("/orders", response_model=List[OrderOut]) def admin_orders(status: Optional[str] = None, db: Session = Depends(get_db)): q = db.query(Order) if status: q = q.filter(Order.status == status) return q.order_by(Order.created_at.desc()).all() @router.post("/orders/{order_id}/status", response_model=OrderOut) def set_order_status(order_id, body: StatusUpdate, db: Session = Depends(get_db)): if body.status not in ORDER_STATUSES: raise HTTPException(status_code=400, detail=f"invalid status; allowed: {sorted(ORDER_STATUSES)}") order = db.query(Order).filter(Order.id == order_id).first() if order is None: raise HTTPException(status_code=404, detail="order not found") old = order.status order.status = body.status db.add( AuditLog( actor="admin", action="order_status_changed", entity="order", entity_id=order.order_number, detail={"from": old, "to": body.status}, ) ) db.commit() db.refresh(order) return order @router.post("/import") def admin_import(body: ImportRequest, db: Session = Depends(get_db)): from app.engines.importer import import_feed supplier = db.query(Supplier).filter(Supplier.id == body.supplier_id).first() if supplier is None: raise HTTPException(status_code=404, detail="supplier not found") summary = import_feed(db, supplier, feed_type=body.feed_type, data_or_url=body.data_or_url) db.add( AuditLog( actor="admin", action="import_requested", entity="supplier", entity_id=str(supplier.id), detail={"feed_type": body.feed_type}, ) ) db.commit() return summary @router.get("/price-rules") def price_rules(db: Session = Depends(get_db)): return [ { "id": r.id, "min_cost": float(r.min_cost), "max_cost": float(r.max_cost) if r.max_cost is not None else None, "markup_pct": float(r.markup_pct), "min_margin_pct": float(r.min_margin_pct or 0), "active": r.active, } for r in db.query(PriceRule).order_by(PriceRule.min_cost).all() ] def _dispatch(task_name, *args): """Prefer Celery; fall back to running inline when the broker is unreachable.""" from app.tasks import recalc_prices, sync_inventory fn = {"recalc_prices": recalc_prices, "sync_inventory": sync_inventory}[task_name] try: result = fn.delay(*args) return {"dispatched": True, "task_id": result.id} except Exception: # broker down (e.g. running on host without redis DNS) — run inline return {"dispatched": False, "inline_result": fn.run(*args)} @router.post("/recalc") def admin_recalc(): return _dispatch("recalc_prices") @router.post("/sync-inventory") def admin_sync_inventory(supplier_id: Optional[str] = None): return _dispatch("sync_inventory", supplier_id)