Files
reseller-platform/backend/app/routers/admin.py

161 lines
5.1 KiB
Python

"""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)