# 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 app.modules.analytics.schemas import VendorStatsResponse from models.schema.vendor import ( LetzshopExportRequest, LetzshopExportResponse, 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}"', }, ) @router.post("/{vendor_identifier}/export/letzshop", response_model=LetzshopExportResponse) def export_vendor_products_letzshop_to_folder( vendor_identifier: str = Path(..., description="Vendor ID or vendor_code"), request: LetzshopExportRequest = None, db: Session = Depends(get_db), current_admin: User = Depends(get_current_admin_api), ): """ Export vendor products to Letzshop pickup folder (Admin only). Generates CSV files for all languages (FR, DE, EN) and places them in a folder that Letzshop scheduler can fetch from. This is the preferred method for automated product sync. **Behavior:** - When Celery is enabled: Queues export as background task, returns immediately - When Celery is disabled: Runs synchronously and returns file paths - Creates CSV files for each language (fr, de, en) - Places files in: exports/letzshop/{vendor_code}/ - Filename format: {vendor_code}_products_{language}.csv Returns: JSON with export status and file paths (or task_id if async) """ import os from datetime import UTC, datetime from pathlib import Path as FilePath from app.core.config import settings from app.services.letzshop_export_service import letzshop_export_service vendor = vendor_service.get_vendor_by_identifier(db, vendor_identifier) include_inactive = request.include_inactive if request else False # If Celery is enabled, dispatch as async task if settings.use_celery: from app.tasks.dispatcher import task_dispatcher celery_task_id = task_dispatcher.dispatch_product_export( vendor_id=vendor.id, triggered_by=f"admin:{current_admin.id}", include_inactive=include_inactive, ) return { "success": True, "message": f"Export task queued for vendor {vendor.vendor_code}. Check Flower for status.", "vendor_code": vendor.vendor_code, "export_directory": f"exports/letzshop/{vendor.vendor_code.lower()}", "files": [], "celery_task_id": celery_task_id, "is_async": True, } # Synchronous export (when Celery is disabled) started_at = datetime.now(UTC) # Create export directory export_dir = FilePath(f"exports/letzshop/{vendor.vendor_code.lower()}") export_dir.mkdir(parents=True, exist_ok=True) exported_files = [] languages = ["fr", "de", "en"] total_records = 0 failed_count = 0 for lang in languages: try: csv_content = letzshop_export_service.export_vendor_products( db=db, vendor_id=vendor.id, language=lang, include_inactive=include_inactive, ) filename = f"{vendor.vendor_code.lower()}_products_{lang}.csv" filepath = export_dir / filename with open(filepath, "w", encoding="utf-8") as f: f.write(csv_content) # Count lines (minus header) line_count = csv_content.count("\n") if line_count > 0: total_records = max(total_records, line_count - 1) exported_files.append({ "language": lang, "filename": filename, "path": str(filepath), "size_bytes": os.path.getsize(filepath), }) except Exception as e: failed_count += 1 exported_files.append({ "language": lang, "error": str(e), }) # Log the export operation via service completed_at = datetime.now(UTC) letzshop_export_service.log_export( db=db, vendor_id=vendor.id, started_at=started_at, completed_at=completed_at, files_processed=len(languages), files_succeeded=len(languages) - failed_count, files_failed=failed_count, products_exported=total_records, triggered_by=f"admin:{current_admin.id}", error_details={"files": exported_files} if failed_count > 0 else None, ) db.commit() return { "success": True, "message": f"Exported {len([f for f in exported_files if 'error' not in f])} language(s) to {export_dir}", "vendor_code": vendor.vendor_code, "export_directory": str(export_dir), "files": exported_files, "is_async": False, }