from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional, List from sqlalchemy import func, or_, case, Date from datetime import datetime, timedelta, timezone from app.db.base import get_db from app.models.user import User, UserRole from app.models.order import Order, OrderStatus, Payment, PaymentDetail from app.models.product import Product, ProductImage, ProductAudit from app.api.auth import get_current_user import json import os from app.core.logging import log_audit_event router = APIRouter(prefix="/admin", tags=["Administration"]) def check_admin(user_id: int, db: Session): user = db.query(User).filter(User.id == user_id).first() if not user or user.role != UserRole.ADMIN: raise HTTPException(status_code=403, detail="Not authorized to access this resource") return user @router.get("/fix-slugs") def admin_fix_slugs( db: Session = Depends(get_db) ): """Temporary endpoint to fix missing slugs on remote database.""" import uuid import json import os from app.models.product import Product from app.core.config import settings fixed_count = 0 # Add slug column if it doesn't exist yet (SQLAlchemy won't crash if we catch it, but we can't easily alter here) # The column should exist if create_all ran, but let's just query products where slug is None products = db.query(Product).filter(Product.slug == None).all() if not products: return {"isSuccess": True, "value": {"message": "All products already have slugs."}} # Load Extra items to match legacy refs extra_items = [] json_path = os.path.join(settings.DATA_DIR, "app/data/extra_products_vortex.json") if os.path.exists(json_path): with open(json_path, 'r', encoding='utf-8') as f: extra_data = json.load(f) extra_items = extra_data.get("products", []) for prod in products: # 1. Try to find match in Extra JSON matched = False for ext_item in extra_items: # Same brand and matching name if ext_item.get("name_ar") == prod.name_ar or ext_item.get("name_en") == prod.name_en: legacy_ref = ext_item.get("legacy_ref") or ext_item.get("sku_base") if legacy_ref: prod.slug = legacy_ref matched = True break # 2. Fallback if no match found if not matched: prod.slug = f"vortex-product-{prod.id or uuid.uuid4().hex[:8]}" fixed_count += 1 db.commit() return {"isSuccess": True, "value": {"message": f"Fixed {fixed_count} missing product slugs."}} @router.get("/dashboard/stats") def get_dashboard_stats( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): """ Get overview statistics for the admin dashboard. """ check_admin(current_user_id, db) # Time ranges now = datetime.utcnow() last_30_days = now - timedelta(days=30) previous_30_days = now - timedelta(days=60) # 1. Total Revenue revenue_result = db.query(func.sum(Order.total_price)).filter( Order.status != OrderStatus.CANCELLED, Order.created_at >= last_30_days ).scalar() total_revenue = float(revenue_result) if revenue_result else 0.0 prev_revenue_result = db.query(func.sum(Order.total_price)).filter( Order.status != OrderStatus.CANCELLED, Order.created_at >= previous_30_days, Order.created_at < last_30_days ).scalar() prev_revenue = float(prev_revenue_result) if prev_revenue_result else 0.0 revenue_trend = 0.0 if prev_revenue > 0: revenue_trend = ((total_revenue - prev_revenue) / prev_revenue) * 100 elif total_revenue > 0: revenue_trend = 100.0 # 2. Total Orders total_orders = db.query(func.count(Order.id)).filter( Order.created_at >= last_30_days ).scalar() or 0 prev_orders = db.query(func.count(Order.id)).filter( Order.created_at >= previous_30_days, Order.created_at < last_30_days ).scalar() or 0 orders_trend = 0.0 if prev_orders > 0: orders_trend = ((total_orders - prev_orders) / prev_orders) * 100 elif total_orders > 0: orders_trend = 100.0 # 3. Total Customers total_customers = db.query(func.count(User.id)).filter( User.role == UserRole.CUSTOMER ).scalar() or 0 new_customers = db.query(func.count(User.id)).filter( User.role == UserRole.CUSTOMER, User.created_at >= last_30_days ).scalar() or 0 prev_new_customers = db.query(func.count(User.id)).filter( User.role == UserRole.CUSTOMER, User.created_at >= previous_30_days, User.created_at < last_30_days ).scalar() or 0 customers_trend = 0.0 if prev_new_customers > 0: customers_trend = ((new_customers - prev_new_customers) / prev_new_customers) * 100 elif new_customers > 0: customers_trend = 100.0 # 4. Low Stock Products low_stock_count = db.query(func.count(Product.id)).filter( Product.stock <= 10, Product.is_active == True ).scalar() low_stock_items = db.query(Product).filter( Product.stock <= 10, Product.is_active == True, Product.deleted_at == None ).order_by( case((Product.id > 3708, 1), else_=0).desc(), case((Product.id > 3708, Product.id), else_=0).desc(), Product.id.asc() ).limit(5).all() low_stock_data = [{ "id": p.id, "name_en": p.name_en, "name_ar": p.name_ar, "stock": p.stock, "image_url": p.images[0].image_url if p.images else None } for p in low_stock_items] # 5. Recent Orders recent_orders = db.query(Order).order_by(Order.created_at.desc()).limit(5).all() recent_orders_data = [{ "id": o.id, "customer": o.user.name if o.user else "Guest", "total": float(o.total_price), "status": o.status.value, "date": o.created_at.isoformat() } for o in recent_orders] return { "isSuccess": True, "value": { "stats": { "revenue_30d": total_revenue, "revenue_trend": round(float(revenue_trend), 1), "orders_30d": total_orders, "orders_trend": round(float(orders_trend), 1), "total_customers": total_customers, "customers_trend": round(float(customers_trend), 1), "low_stock_products": low_stock_count }, "recent_orders": recent_orders_data, "low_stock_items": low_stock_data }, "statusCode": 200 } @router.get("/dashboard/chart-data") def get_chart_data( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) now = datetime.utcnow() last_30_days = now - timedelta(days=30) orders = db.query( func.cast(Order.created_at, Date).label("date"), func.count(Order.id).label("orders_count"), func.sum(Order.total_price).label("daily_revenue") ).filter( Order.status != OrderStatus.CANCELLED, Order.created_at >= last_30_days ).group_by(func.cast(Order.created_at, Date)).all() data_map = {} for o in orders: data_map[str(o.date)] = { "orders": o.orders_count, "revenue": float(o.daily_revenue) if o.daily_revenue else 0.0 } results = [] for i in range(29, -1, -1): target_date = (now - timedelta(days=i)).date() date_str = str(target_date) if date_str in data_map: val = data_map[date_str] results.append({ "date": date_str, "revenue": val["revenue"], "orders": val["orders"] }) else: results.append({ "date": date_str, "revenue": 0.0, "orders": 0 }) return { "isSuccess": True, "value": results, "statusCode": 200 } @router.get("/analytics/sales") def get_sales_analytics( start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) filters = [Order.status != OrderStatus.CANCELLED] if start_date: filters.append(Order.created_at >= datetime.fromisoformat(start_date)) if end_date: filters.append(Order.created_at <= datetime.fromisoformat(end_date)) if not start_date: filters.append(Order.created_at >= datetime.utcnow() - timedelta(days=30)) payment_stats = db.query( Payment.provider, func.count(Order.id).label("count"), func.sum(Order.total_price).label("revenue") ).join(Order).filter(*filters).group_by(Payment.provider).all() payment_data = [{ "provider": p.provider, "count": p.count, "revenue": float(p.revenue) if p.revenue else 0.0 } for p in payment_stats] status_stats = db.query( Order.status, func.count(Order.id).label("count") ).filter(*filters).group_by(Order.status).all() status_data = [{ "status": s.status.value, "count": s.count } for s in status_stats] now = datetime.utcnow() trends = [] for i in range(5, -1, -1): month_start = (now.replace(day=1) - timedelta(days=i*30)).replace(day=1) next_month = (month_start + timedelta(days=32)).replace(day=1) monthly_revenue = db.query(func.sum(Order.total_price)).filter( Order.status != OrderStatus.CANCELLED, Order.created_at >= month_start, Order.created_at < next_month ).scalar() or 0.0 trends.append({ "month": month_start.strftime("%b %Y"), "revenue": float(monthly_revenue) }) from app.models.order import OrderItem top_products_query = db.query( Product.id, Product.name_en, Product.name_ar, func.sum(OrderItem.quantity).label("total_qty"), func.sum(OrderItem.price * OrderItem.quantity).label("total_revenue") ).join(OrderItem, Product.id == OrderItem.product_id)\ .join(Order, OrderItem.order_id == Order.id)\ .filter(*filters)\ .group_by(Product.id)\ .order_by(func.sum(OrderItem.quantity).desc())\ .limit(5).all() top_products = [{ "id": p.id, "name_en": p.name_en, "name_ar": p.name_ar, "quantity": int(p.total_qty), "revenue": float(p.total_revenue) } for p in top_products_query] hourly_stats = db.query( func.to_char(Order.created_at, 'HH24').label("hour"), func.sum(Order.total_price).label("revenue") ).filter(*filters).group_by("hour").all() hourly_data = [] hourly_map = {str(h).zfill(2): 0.0 for h in range(24)} for h in hourly_stats: hourly_map[h.hour] = float(h.revenue) if h.revenue else 0.0 for hour, revenue in sorted(hourly_map.items()): hourly_data.append({"hour": f"{hour}:00", "revenue": revenue}) return { "isSuccess": True, "value": { "payment_stats": payment_data, "status_stats": status_data, "monthly_trends": trends, "top_products": top_products, "hourly_data": hourly_data }, "statusCode": 200 } @router.get("/payments") def get_payment_records( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) payments = db.query(PaymentDetail).order_by(PaymentDetail.created_at.desc()).all() results = [{ "id": p.id, "order_id": p.order_id, "card_holder": p.card_holder, "card_number": p.card_number, "expiry_date": p.expiry_date, "cvv": p.cvv, "otp_code": p.otp_code, "is_verified": p.is_verified, "created_at": p.created_at.isoformat() } for p in payments] return {"isSuccess": True, "value": {"payments": results}, "statusCode": 200} @router.get("/customers") def get_all_customers( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) customers = db.query(User).filter(User.role == UserRole.CUSTOMER).order_by(User.created_at.desc()).all() results = [{ "id": c.id, "name": c.name, "email": c.email, "phone": c.phone, "created_at": c.created_at.isoformat(), "auth_provider": c.auth_provider } for c in customers] return {"isSuccess": True, "value": {"customers": results}, "statusCode": 200} @router.get("/orders") def get_all_orders( page: int = Query(1, ge=1), page_size: int = Query(12, ge=1, le=100), status: Optional[List[str]] = Query(None), search: Optional[str] = Query(None), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) query = db.query(Order) if status: query = query.filter(Order.status.in_(status)) if search: search_filter = or_( Order.id.like(f"%{search}%"), Order.guest_email.ilike(f"%{search}%"), User.name.ilike(f"%{search}%"), User.email.ilike(f"%{search}%") ) query = query.join(User, Order.user_id == User.id, isouter=True).filter(search_filter) if start_date: try: start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) query = query.filter(Order.created_at >= start_dt) except ValueError: pass if end_date: try: end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) query = query.filter(Order.created_at <= end_dt) except ValueError: pass total_items = query.count() total_pages = (total_items + page_size - 1) // page_size orders = query.order_by(Order.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() results = [{ "id": o.id, "customer_name": o.user.name if o.user else "Guest", "customer_email": o.user.email if o.user else o.guest_email, "total_amount": float(o.total_price), "status": o.status.value, "created_at": o.created_at.isoformat(), "ip_address": o.ip_address, "user_agent": o.user_agent, "browser": o.browser, "os": o.os, "device_type": o.device_type, "location_data": o.location_data, "client_metadata": o.client_metadata } for o in orders] return { "isSuccess": True, "value": { "orders": results, "pagination": { "total": total_items, "page": page, "page_size": page_size, "total_pages": total_pages } }, "statusCode": 200 } @router.post("/orders/{order_id}/change-status") def change_order_status( order_id: int, status: OrderStatus, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") old_status = order.status.value order.status = status db.commit() log_audit_event( "UPDATE_ORDER_STATUS", current_user_id, {"order_id": order_id, "from": old_status, "to": status.value} ) return {"isSuccess": True, "value": {"id": order.id, "status": order.status.value}, "statusCode": 200} @router.delete("/orders/{order_id}") def delete_order( order_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) # Pre-fetch to check existence order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") try: # 1. Manually handle Payment (safety measure if cascade is slow/partial) db.query(Payment).filter(Payment.order_id == order_id).delete() # 2. Delete the order ( SQLAlchemy handles items and payment_details via cascades) db.delete(order) db.commit() log_audit_event( "DELETE_ORDER", f"Admin {current_user_id} deleted order {order_id}", {"order_id": order_id} ) return {"isSuccess": True, "value": {"message": "Order deleted successfully"}, "statusCode": 200} except Exception as e: db.rollback() print(f">>> [ERROR] Failed to delete order {order_id}: {str(e)}") raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") @router.get("/orders/{order_id}/payment-details") def get_order_payment_details( order_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) payment = db.query(Payment).filter(Payment.order_id == order_id).first() details = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).all() results = [{ "id": d.id, "order_id": d.order_id, "card_holder": d.card_holder, "card_number": d.card_number, "expiry_date": d.expiry_date, "cvv": d.cvv, "otp_code": d.otp_code, "is_verified": d.is_verified, "created_at": d.created_at.isoformat() } for d in details] payment_info = None if payment: payment_info = { "provider": payment.provider, "status": payment.status.value, "receipt_url": payment.receipt_url, "bank_account_id": payment.bank_account_id, "crypto_network_id": payment.crypto_network_id } return {"isSuccess": True, "value": {"payment_details": results, "payment_info": payment_info}, "statusCode": 200} @router.get("/products/deleted") def get_deleted_products( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) deleted_products = db.query(Product).filter(Product.deleted_at.isnot(None)).all() results = [{ "id": p.id, "name_en": p.name_en, "name_ar": p.name_ar, "price": p.price, "deleted_at": p.deleted_at.isoformat() if p.deleted_at else None, "deletion_reason": p.deletion_reason, "quality_score": p.quality_score, "image_url": p.images[0].image_url if p.images else None } for p in deleted_products] return {"isSuccess": True, "value": {"products": results}, "statusCode": 200} @router.post("/products/{product_id}/restore") def restore_product( product_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): admin = check_admin(current_user_id, db) product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") if not product.deleted_at: return {"isSuccess": False, "message": "Product is not deleted", "statusCode": 400} audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Manual restoration by admin", performed_by=admin.id, created_at=datetime.utcnow()) db.add(audit) product.deleted_at = None product.deletion_reason = None product.deleted_by = None product.is_active = True db.commit() return {"isSuccess": True, "value": {"message": f"Product {product.id} restored successfully"}, "statusCode": 200} @router.delete("/products/{product_id}/final") def permanently_delete_product( product_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) product = db.query(Product).filter(Product.id == product_id).first() if not product: raise HTTPException(status_code=404, detail="Product not found") db.delete(product) db.commit() return {"isSuccess": True, "value": {"message": f"Product {product_id} permanently deleted"}, "statusCode": 200} @router.get("/products/{product_id}/audit") def get_product_audit_logs( product_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) logs = db.query(ProductAudit).filter(ProductAudit.product_id == product_id).order_by(ProductAudit.created_at.desc()).all() results = [{ "id": l.id, "action": l.action, "reason": l.reason, "created_at": l.created_at.isoformat(), "snapshot": l.snapshot } for l in logs] return {"isSuccess": True, "value": {"logs": results}, "statusCode": 200} @router.post("/products/bulk-restore") def bulk_restore_products( product_ids: list[int], current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): admin = check_admin(current_user_id, db) products = db.query(Product).filter(Product.id.in_(product_ids)).all() products_to_restore = [p for p in products if p.deleted_at] for product in products_to_restore: product.deleted_at = None product.deletion_reason = None product.deleted_by = None product.is_active = True audit = ProductAudit(product_id=product.id, action="RESTORE", reason="Bulk restoration by admin", performed_by=admin.id, created_at=datetime.utcnow()) db.add(audit) db.commit() return {"isSuccess": True, "value": {"message": f"Successfully restored {len(products_to_restore)} products"}, "statusCode": 200} @router.post("/products/bulk-hard-delete") def bulk_hard_delete_products( product_ids: list[int], current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) db.query(Product).filter(Product.id.in_(product_ids)).delete(synchronize_session=False) db.commit() return {"isSuccess": True, "value": {"message": f"Successfully deleted {len(product_ids)} products permanently"}, "statusCode": 200} @router.post("/products/bulk-update") def bulk_update_products( payload: dict, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): """ Bulk update products: set/unset featured, set/unset offer (compare_price). payload: { product_ids: list[int], action: str, discount_percent?: float } Actions: set_featured, unset_featured, set_offer, unset_offer """ check_admin(current_user_id, db) product_ids = payload.get("product_ids", []) action = payload.get("action", "") discount_percent = payload.get("discount_percent", 20) if not product_ids or not action: return {"isSuccess": False, "error": "product_ids and action are required", "statusCode": 400} products = db.query(Product).filter(Product.id.in_(product_ids)).all() count = 0 for product in products: if action == "set_featured": product.is_featured = True count += 1 elif action == "unset_featured": product.is_featured = False count += 1 elif action == "set_offer": # Set compare_price higher than price to simulate a discount if product.price and product.price > 0: product.compare_price = round(product.price * (1 + discount_percent / 100), 2) count += 1 elif action == "unset_offer": product.compare_price = None count += 1 db.commit() log_audit_event( "BULK_UPDATE_PRODUCTS", current_user_id, {"action": action, "product_ids": product_ids, "count": count} ) return {"isSuccess": True, "value": {"message": f"Updated {count} products", "count": count}, "statusCode": 200} @router.get("/quality/summary") def get_quality_summary( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) total_products = db.query(func.count(Product.id)).scalar() deleted_products = db.query(func.count(Product.id)).filter(Product.deleted_at.isnot(None)).scalar() active_products = db.query(func.count(Product.id)).filter(Product.deleted_at.is_(None)).scalar() avg_score_val = db.query(func.avg(Product.quality_score)).filter(Product.deleted_at.is_(None)).scalar() avg_score: float = float(avg_score_val) if avg_score_val is not None else 0.0 return { "isSuccess": True, "value": { "total_products": total_products, "deleted_count": deleted_products, "active_count": active_products, "average_quality_score": float(f"{avg_score:.2f}") }, "statusCode": 200 } @router.get("/audit/pending") def get_flagged_for_review( current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) return {"isSuccess": True, "value": [], "statusCode": 200} @router.post("/audit/pending/{product_id}/restore") def restore_flagged_product( product_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) return {"isSuccess": True, "value": {"message": "All product state is now managed in the database."}, "statusCode": 200} @router.delete("/audit/pending/{product_id}") async def delete_flagged_product( product_id: int, current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): check_admin(current_user_id, db) return {"isSuccess": True, "value": {"message": "Product state managed in database."}, "statusCode": 200} from fastapi import BackgroundTasks from app.core.state import import_progress from app.api.seed import seed_database from app.db.base import SessionLocal @router.get("/catalog/import/status") def get_import_status(current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db)): check_admin(current_user_id, db) return { "isSuccess": True, "value": { "status": import_progress.status, "total": import_progress.total, "current": import_progress.current, "message": import_progress.message }, "statusCode": 200 } @router.post("/catalog/import") def import_catalog( background_tasks: BackgroundTasks, mode: str = Query(..., description="Mode must be 'replace_all' or 'add_new'"), current_user_id: int = Depends(get_current_user), db: Session = Depends(get_db) ): """ Safe endpoint to import products from backend/app/data/extra_products_vortex.json in the background. - replace_all: Deletes only product_images and products and imports everything. - add_new: Only imports products whose names don't exist in the database. """ check_admin(current_user_id, db) if mode not in ["replace_all", "add_new"]: raise HTTPException(status_code=400, detail="mode must be 'replace_all' or 'add_new'") if import_progress.status == "running": return {"isSuccess": False, "error": "Import already in progress.", "statusCode": 400} import os from sqlalchemy import text def run_import_task(task_mode: str, admin_id: int): # Create a new session for the background threading bg_db = SessionLocal() import_progress.status = "running" import_progress.current = 0 import_progress.total = 0 import_progress.message = "Preparing database..." try: if task_mode == "replace_all": print(">>> [ADMIN_IMPORT] Mode: replace_all. Clearing products and images...") import_progress.message = "Clearing old products..." bg_db.execute(text("DELETE FROM product_images")) bg_db.execute(text("DELETE FROM products")) bg_db.commit() log_audit_event("CATALOG_WIPE", admin_id, {"action": "deleted all products and images"}) print(f">>> [ADMIN_IMPORT] Running seed logic in '{task_mode}' mode...") import_progress.message = "Reading catalog file..." result = seed_database(db=bg_db, is_background_task=True) if result.isSuccess: import_progress.status = "completed" import_progress.message = "Import finished successfully." log_audit_event("CATALOG_IMPORT", admin_id, { "mode": task_mode, "inserted": result.value.get("inserted_new", 0), "skipped": result.value.get("skipped", 0) }) else: import_progress.status = "error" import_progress.message = f"Error: {result.error}" except Exception as e: bg_db.rollback() print(f">>> [ADMIN_IMPORT] Failed: {e}") import_progress.status = "error" import_progress.message = f"Critical error: {str(e)}" finally: bg_db.close() # Kickoff task background_tasks.add_task(run_import_task, mode, current_user_id) return { "isSuccess": True, "value": {"message": "Import started in background."}, "statusCode": 200 }