- Create comprehensive stats schemas in models/schema/stats.py: - ImportStatsResponse, UserStatsResponse, ProductStatsResponse - PlatformStatsResponse, AdminDashboardResponse - VendorDashboardStatsResponse with nested models - VendorAnalyticsResponse, CodeQualityDashboardStatsResponse - Move DashboardStatsResponse from code_quality.py to schema file - Fix get_vendor_statistics() to return pending_vendors field - Fix get_vendor_stats() to return flat structure matching schema - Add response_model to all stats endpoints: - GET /admin/dashboard -> AdminDashboardResponse - GET /admin/dashboard/stats/platform -> PlatformStatsResponse - GET /admin/marketplace-import-jobs/stats -> ImportStatsResponse - GET /vendor/dashboard/stats -> VendorDashboardStatsResponse - GET /vendor/analytics -> VendorAnalyticsResponse - Enhance API-001 architecture rule with detailed guidance - Add SVC-007 rule for service/schema compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
# app/api/v1/admin/vendors.py
|
|
"""
|
|
Vendor management endpoints for admin.
|
|
|
|
Architecture Notes:
|
|
- All business logic is in vendor_service (no direct DB operations here)
|
|
- Uses domain exceptions from app/exceptions/vendor.py
|
|
- Exception handler middleware converts domain exceptions to HTTP responses
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Body, Depends, Path, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_admin_api
|
|
from app.core.database import get_db
|
|
from app.exceptions import ConfirmationRequiredException
|
|
from app.services.admin_service import admin_service
|
|
from app.services.stats_service import stats_service
|
|
from app.services.vendor_service import vendor_service
|
|
from models.database.user import User
|
|
from models.schema.stats import VendorStatsResponse
|
|
from models.schema.vendor import (
|
|
VendorCreate,
|
|
VendorCreateResponse,
|
|
VendorDetailResponse,
|
|
VendorListResponse,
|
|
VendorUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/vendors")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@router.post("", response_model=VendorCreateResponse)
|
|
def create_vendor(
|
|
vendor_data: VendorCreate,
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Create a new vendor (storefront/brand) under an existing company (Admin only).
|
|
|
|
This endpoint:
|
|
1. Validates that the parent company exists
|
|
2. Creates a new vendor record linked to the company
|
|
3. Sets up default roles (Owner, Manager, Editor, Viewer)
|
|
|
|
The vendor inherits owner and contact information from its parent company.
|
|
"""
|
|
vendor = admin_service.create_vendor(db=db, vendor_data=vendor_data)
|
|
db.commit()
|
|
|
|
return VendorCreateResponse(
|
|
# Vendor fields
|
|
id=vendor.id,
|
|
vendor_code=vendor.vendor_code,
|
|
subdomain=vendor.subdomain,
|
|
name=vendor.name,
|
|
description=vendor.description,
|
|
company_id=vendor.company_id,
|
|
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
|
is_active=vendor.is_active,
|
|
is_verified=vendor.is_verified,
|
|
created_at=vendor.created_at,
|
|
updated_at=vendor.updated_at,
|
|
# Company info
|
|
company_name=vendor.company.name,
|
|
company_contact_email=vendor.company.contact_email,
|
|
company_contact_phone=vendor.company.contact_phone,
|
|
company_website=vendor.company.website,
|
|
# Owner info (from company)
|
|
owner_email=vendor.company.owner.email,
|
|
owner_username=vendor.company.owner.username,
|
|
login_url=f"http://localhost:8000/vendor/{vendor.subdomain}/login",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=VendorListResponse)
|
|
def get_all_vendors_admin(
|
|
skip: int = Query(0, ge=0),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
search: str | None = Query(None, description="Search by name or vendor code"),
|
|
is_active: bool | None = Query(None),
|
|
is_verified: bool | None = Query(None),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""Get all vendors with filtering (Admin only)."""
|
|
vendors, total = admin_service.get_all_vendors(
|
|
db=db,
|
|
skip=skip,
|
|
limit=limit,
|
|
search=search,
|
|
is_active=is_active,
|
|
is_verified=is_verified,
|
|
)
|
|
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
|
|
|
|
|
|
@router.get("/stats", response_model=VendorStatsResponse)
|
|
def get_vendor_statistics_endpoint(
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""Get vendor statistics for admin dashboard (Admin only)."""
|
|
stats = stats_service.get_vendor_statistics(db)
|
|
|
|
# Use schema-compatible keys (with fallback to legacy keys)
|
|
return VendorStatsResponse(
|
|
total=stats.get("total", stats.get("total_vendors", 0)),
|
|
verified=stats.get("verified", stats.get("verified_vendors", 0)),
|
|
pending=stats.get("pending", stats.get("pending_vendors", 0)),
|
|
inactive=stats.get("inactive", stats.get("inactive_vendors", 0)),
|
|
)
|
|
|
|
|
|
def _build_vendor_detail_response(vendor) -> VendorDetailResponse:
|
|
"""
|
|
Helper to build VendorDetailResponse with resolved contact info.
|
|
|
|
Contact fields are resolved using vendor override or company fallback.
|
|
Inheritance flags indicate if value comes from company.
|
|
"""
|
|
contact_info = vendor.get_contact_info_with_inheritance()
|
|
|
|
return VendorDetailResponse(
|
|
# Vendor fields
|
|
id=vendor.id,
|
|
vendor_code=vendor.vendor_code,
|
|
subdomain=vendor.subdomain,
|
|
name=vendor.name,
|
|
description=vendor.description,
|
|
company_id=vendor.company_id,
|
|
letzshop_csv_url_fr=vendor.letzshop_csv_url_fr,
|
|
letzshop_csv_url_en=vendor.letzshop_csv_url_en,
|
|
letzshop_csv_url_de=vendor.letzshop_csv_url_de,
|
|
is_active=vendor.is_active,
|
|
is_verified=vendor.is_verified,
|
|
created_at=vendor.created_at,
|
|
updated_at=vendor.updated_at,
|
|
# Company info
|
|
company_name=vendor.company.name,
|
|
# Owner details (from company)
|
|
owner_email=vendor.company.owner.email,
|
|
owner_username=vendor.company.owner.username,
|
|
# Resolved contact info with inheritance flags
|
|
**contact_info,
|
|
# Original company values for UI reference
|
|
company_contact_email=vendor.company.contact_email,
|
|
company_contact_phone=vendor.company.contact_phone,
|
|
company_website=vendor.company.website,
|
|
company_business_address=vendor.company.business_address,
|
|
company_tax_number=vendor.company.tax_number,
|
|
)
|
|
|
|
|
|
@router.get("/{vendor_identifier}", response_model=VendorDetailResponse)
|
|
def get_vendor_details(
|
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Get detailed vendor information including company and owner details (Admin only).
|
|
|
|
Accepts either vendor ID (integer) or vendor_code (string).
|
|
|
|
Returns vendor info with company contact details, owner info, and
|
|
resolved contact fields (vendor override or company default).
|
|
|
|
Raises:
|
|
VendorNotFoundException: If vendor not found (404)
|
|
"""
|
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
|
return _build_vendor_detail_response(vendor)
|
|
|
|
|
|
@router.put("/{vendor_identifier}", response_model=VendorDetailResponse)
|
|
def update_vendor(
|
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
vendor_update: VendorUpdate = Body(...),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Update vendor information (Admin only).
|
|
|
|
Accepts either vendor ID (integer) or vendor_code (string).
|
|
|
|
**Can update:**
|
|
- Basic info: name, description, subdomain
|
|
- Marketplace URLs
|
|
- Status: is_active, is_verified
|
|
- Contact info: contact_email, contact_phone, website, business_address, tax_number
|
|
(these override company defaults; set to empty to reset to inherit)
|
|
|
|
**Cannot update:**
|
|
- `vendor_code` (immutable)
|
|
|
|
Raises:
|
|
VendorNotFoundException: If vendor not found (404)
|
|
"""
|
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
|
vendor = admin_service.update_vendor(db, vendor.id, vendor_update)
|
|
db.commit()
|
|
return _build_vendor_detail_response(vendor)
|
|
|
|
|
|
# NOTE: Ownership transfer is now at the Company level.
|
|
# Use PUT /api/v1/admin/companies/{id}/transfer-ownership instead.
|
|
# This endpoint is kept for backwards compatibility but may be removed in future versions.
|
|
|
|
|
|
@router.put("/{vendor_identifier}/verification", response_model=VendorDetailResponse)
|
|
def toggle_vendor_verification(
|
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
verification_data: dict = Body(..., example={"is_verified": True}),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Set vendor verification status (Admin only).
|
|
|
|
Accepts either vendor ID (integer) or vendor_code (string).
|
|
|
|
Request body: { "is_verified": true/false }
|
|
|
|
Raises:
|
|
VendorNotFoundException: If vendor not found (404)
|
|
"""
|
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
|
|
|
if "is_verified" in verification_data:
|
|
vendor, message = vendor_service.set_verification(
|
|
db, vendor.id, verification_data["is_verified"]
|
|
)
|
|
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
|
logger.info(f"Vendor verification updated: {message}")
|
|
|
|
return _build_vendor_detail_response(vendor)
|
|
|
|
|
|
@router.put("/{vendor_identifier}/status", response_model=VendorDetailResponse)
|
|
def toggle_vendor_status(
|
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
status_data: dict = Body(..., example={"is_active": True}),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Set vendor active status (Admin only).
|
|
|
|
Accepts either vendor ID (integer) or vendor_code (string).
|
|
|
|
Request body: { "is_active": true/false }
|
|
|
|
Raises:
|
|
VendorNotFoundException: If vendor not found (404)
|
|
"""
|
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
|
|
|
if "is_active" in status_data:
|
|
vendor, message = vendor_service.set_status(
|
|
db, vendor.id, status_data["is_active"]
|
|
)
|
|
db.commit() # ✅ ARCH: Commit at API level for transaction control
|
|
logger.info(f"Vendor status updated: {message}")
|
|
|
|
return _build_vendor_detail_response(vendor)
|
|
|
|
|
|
@router.delete("/{vendor_identifier}")
|
|
def delete_vendor(
|
|
vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"),
|
|
confirm: bool = Query(False, description="Must be true to confirm deletion"),
|
|
db: Session = Depends(get_db),
|
|
current_admin: User = Depends(get_current_admin_api),
|
|
):
|
|
"""
|
|
Delete vendor and all associated data (Admin only).
|
|
|
|
Accepts either vendor ID (integer) or vendor_code (string).
|
|
|
|
⚠️ **WARNING: This is destructive and will delete:**
|
|
- Vendor account
|
|
- All products
|
|
- All orders
|
|
- All customers
|
|
- All team members
|
|
|
|
Requires confirmation parameter: `confirm=true`
|
|
|
|
Raises:
|
|
ConfirmationRequiredException: If confirm=true not provided (400)
|
|
VendorNotFoundException: If vendor not found (404)
|
|
"""
|
|
if not confirm:
|
|
raise ConfirmationRequiredException(
|
|
operation="delete_vendor",
|
|
message="Deletion requires confirmation parameter: confirm=true",
|
|
)
|
|
|
|
vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier)
|
|
message = admin_service.delete_vendor(db, vendor.id)
|
|
db.commit()
|
|
return {"message": message}
|