diff --git a/app/api/v1/admin/customers.py b/app/api/v1/admin/customers.py new file mode 100644 index 00000000..666c34b6 --- /dev/null +++ b/app/api/v1/admin/customers.py @@ -0,0 +1,112 @@ +# app/api/v1/admin/customers.py +""" +Customer management endpoints for admin. + +Provides admin-level access to customer data across all vendors. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.admin_customer_service import admin_customer_service +from models.database.user import User +from models.schema.customer import ( + CustomerDetailResponse, + CustomerListResponse, + CustomerMessageResponse, + CustomerStatisticsResponse, +) + +router = APIRouter(prefix="/customers") + + +# ============================================================================ +# List Customers +# ============================================================================ + + +@router.get("", response_model=CustomerListResponse) +def list_customers( + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + search: str = Query("", description="Search by email, name, or customer number"), + is_active: bool | None = Query(None, description="Filter by active status"), + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerListResponse: + """ + Get paginated list of customers across all vendors. + + Admin can filter by vendor, search, and active status. + """ + customers, total = admin_customer_service.list_customers( + db=db, + vendor_id=vendor_id, + search=search if search else None, + is_active=is_active, + skip=skip, + limit=limit, + ) + + return CustomerListResponse( + customers=customers, + total=total, + skip=skip, + limit=limit, + ) + + +# ============================================================================ +# Customer Statistics +# ============================================================================ + + +@router.get("/stats", response_model=CustomerStatisticsResponse) +def get_customer_stats( + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerStatisticsResponse: + """Get customer statistics.""" + stats = admin_customer_service.get_customer_stats(db=db, vendor_id=vendor_id) + return CustomerStatisticsResponse(**stats) + + +# ============================================================================ +# Get Single Customer +# ============================================================================ + + +@router.get("/{customer_id}", response_model=CustomerDetailResponse) +def get_customer( + customer_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerDetailResponse: + """Get customer details by ID.""" + customer = admin_customer_service.get_customer(db=db, customer_id=customer_id) + return CustomerDetailResponse(**customer) + + +# ============================================================================ +# Toggle Customer Status +# ============================================================================ + + +@router.patch("/{customer_id}/toggle-status", response_model=CustomerMessageResponse) +def toggle_customer_status( + customer_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +) -> CustomerMessageResponse: + """Toggle customer active status.""" + result = admin_customer_service.toggle_customer_status( + db=db, + customer_id=customer_id, + admin_email=current_admin.email, + ) + db.commit() + return CustomerMessageResponse(message=result["message"]) diff --git a/app/services/admin_customer_service.py b/app/services/admin_customer_service.py new file mode 100644 index 00000000..31912e06 --- /dev/null +++ b/app/services/admin_customer_service.py @@ -0,0 +1,242 @@ +# app/services/admin_customer_service.py +""" +Admin customer management service. + +Handles customer operations for admin users across all vendors. +""" + +import logging +from typing import Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.exceptions.customer import CustomerNotFoundException +from models.database.customer import Customer +from models.database.vendor import Vendor + +logger = logging.getLogger(__name__) + + +class AdminCustomerService: + """Service for admin-level customer management across vendors.""" + + def list_customers( + self, + db: Session, + vendor_id: int | None = None, + search: str | None = None, + is_active: bool | None = None, + skip: int = 0, + limit: int = 20, + ) -> tuple[list[dict[str, Any]], int]: + """ + Get paginated list of customers across all vendors. + + Args: + db: Database session + vendor_id: Optional vendor ID filter + search: Search by email, name, or customer number + is_active: Filter by active status + skip: Number of records to skip + limit: Maximum records to return + + Returns: + Tuple of (customers list, total count) + """ + # Build query + query = db.query(Customer).join(Vendor, Customer.vendor_id == Vendor.id) + + # Apply filters + if vendor_id: + query = query.filter(Customer.vendor_id == vendor_id) + + if search: + search_term = f"%{search}%" + query = query.filter( + (Customer.email.ilike(search_term)) + | (Customer.first_name.ilike(search_term)) + | (Customer.last_name.ilike(search_term)) + | (Customer.customer_number.ilike(search_term)) + ) + + if is_active is not None: + query = query.filter(Customer.is_active == is_active) + + # Get total count + total = query.count() + + # Get paginated results with vendor info + customers = ( + query.add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code) + .order_by(Customer.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + # Format response + result = [] + for row in customers: + customer = row[0] + vendor_name = row[1] + vendor_code = row[2] + + customer_dict = { + "id": customer.id, + "vendor_id": customer.vendor_id, + "email": customer.email, + "first_name": customer.first_name, + "last_name": customer.last_name, + "phone": customer.phone, + "customer_number": customer.customer_number, + "marketing_consent": customer.marketing_consent, + "preferred_language": customer.preferred_language, + "last_order_date": customer.last_order_date, + "total_orders": customer.total_orders, + "total_spent": float(customer.total_spent) if customer.total_spent else 0, + "is_active": customer.is_active, + "created_at": customer.created_at, + "updated_at": customer.updated_at, + "vendor_name": vendor_name, + "vendor_code": vendor_code, + } + result.append(customer_dict) + + return result, total + + def get_customer_stats( + self, + db: Session, + vendor_id: int | None = None, + ) -> dict[str, Any]: + """ + Get customer statistics. + + Args: + db: Database session + vendor_id: Optional vendor ID filter + + Returns: + Dict with customer statistics + """ + query = db.query(Customer) + + if vendor_id: + query = query.filter(Customer.vendor_id == vendor_id) + + total = query.count() + active = query.filter(Customer.is_active == True).count() # noqa: E712 + inactive = query.filter(Customer.is_active == False).count() # noqa: E712 + with_orders = query.filter(Customer.total_orders > 0).count() + + # Total spent across all customers + total_spent_result = query.with_entities(func.sum(Customer.total_spent)).scalar() + total_spent = float(total_spent_result) if total_spent_result else 0 + + # Average order value + total_orders_result = query.with_entities(func.sum(Customer.total_orders)).scalar() + total_orders = int(total_orders_result) if total_orders_result else 0 + avg_order_value = total_spent / total_orders if total_orders > 0 else 0 + + return { + "total": total, + "active": active, + "inactive": inactive, + "with_orders": with_orders, + "total_spent": total_spent, + "total_orders": total_orders, + "avg_order_value": round(avg_order_value, 2), + } + + def get_customer( + self, + db: Session, + customer_id: int, + ) -> dict[str, Any]: + """ + Get customer details by ID. + + Args: + db: Database session + customer_id: Customer ID + + Returns: + Customer dict with vendor info + + Raises: + CustomerNotFoundException: If customer not found + """ + result = ( + db.query(Customer) + .join(Vendor, Customer.vendor_id == Vendor.id) + .add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code) + .filter(Customer.id == customer_id) + .first() + ) + + if not result: + raise CustomerNotFoundException(str(customer_id)) + + customer = result[0] + return { + "id": customer.id, + "vendor_id": customer.vendor_id, + "email": customer.email, + "first_name": customer.first_name, + "last_name": customer.last_name, + "phone": customer.phone, + "customer_number": customer.customer_number, + "marketing_consent": customer.marketing_consent, + "preferred_language": customer.preferred_language, + "last_order_date": customer.last_order_date, + "total_orders": customer.total_orders, + "total_spent": float(customer.total_spent) if customer.total_spent else 0, + "is_active": customer.is_active, + "created_at": customer.created_at, + "updated_at": customer.updated_at, + "vendor_name": result[1], + "vendor_code": result[2], + } + + def toggle_customer_status( + self, + db: Session, + customer_id: int, + admin_email: str, + ) -> dict[str, Any]: + """ + Toggle customer active status. + + Args: + db: Database session + customer_id: Customer ID + admin_email: Admin user email for logging + + Returns: + Dict with customer ID, new status, and message + + Raises: + CustomerNotFoundException: If customer not found + """ + customer = db.query(Customer).filter(Customer.id == customer_id).first() + + if not customer: + raise CustomerNotFoundException(str(customer_id)) + + customer.is_active = not customer.is_active + db.flush() + db.refresh(customer) + + status = "activated" if customer.is_active else "deactivated" + logger.info(f"Customer {customer.email} {status} by admin {admin_email}") + + return { + "id": customer.id, + "is_active": customer.is_active, + "message": f"Customer {status} successfully", + } + + +# Singleton instance +admin_customer_service = AdminCustomerService() diff --git a/app/templates/admin/customers.html b/app/templates/admin/customers.html index 3eff32ab..fb2d5bbc 100644 --- a/app/templates/admin/customers.html +++ b/app/templates/admin/customers.html @@ -1,42 +1,292 @@ {# app/templates/admin/customers.html #} {% extends "admin/base.html" %} {% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper, table_header %} {% block title %}Customers{% endblock %} {% block alpine_data %}adminCustomers(){% endblock %} {% block content %} -{{ page_header('Customers', subtitle='Manage platform customers') }} +{{ page_header('Customer Management') }} - -
- Customer management features coming soon. -
-- This section will allow you to view and manage customers across all vendors. -
+{{ loading_state('Loading customers...') }} + +{{ error_state('Error loading customers') }} + + ++ Total Customers +
++ 0 +
++ Active +
++ 0 +
++ With Orders +
++ 0 +
++ Total Revenue +
++ 0 +
+| Customer | +Vendor | +Customer # | +Orders | +Total Spent | +Status | +Joined | +Actions | +
|---|---|---|---|---|---|---|---|
|
+
+ Loading customers... + |
+ |||||||
|
+
+ No customers found +Try adjusting your search or filters + |
+ |||||||
|
+
+
+
+
+
+
+
+ |
+
+
+ + + | + + ++ + | + + ++ + | + + ++ + | + + ++ + | + + ++ + | + + +
+
+
+
+ |
+