# 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} # ============================================================================ # LETZSHOP EXPORT # ============================================================================ @router.get("/{vendor_identifier}/export/letzshop") def export_vendor_products_letzshop( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), language: str = Query( "en", description="Language for title/description (en, fr, de)" ), include_inactive: bool = Query(False, description="Include inactive products"), db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Export vendor products in Letzshop CSV format (Admin only). Generates a Google Shopping compatible CSV file for Letzshop marketplace. The file uses tab-separated values and includes all required Letzshop fields. **Supported languages:** en, fr, de **CSV Format:** - Delimiter: Tab (\\t) - Encoding: UTF-8 - Fields: id, title, description, price, availability, image_link, etc. Returns: CSV file as attachment (vendor_code_letzshop_export.csv) """ from fastapi.responses import Response from app.services.letzshop_export_service import letzshop_export_service vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier) csv_content = letzshop_export_service.export_vendor_products( db=db, vendor_id=vendor.id, language=language, include_inactive=include_inactive, ) filename = f"{vendor.vendor_code.lower()}_letzshop_export.csv" return Response( content=csv_content, media_type="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', }, )