Files
orion/app/api/v1/admin/vendors.py
2025-12-20 22:15:28 +01:00

447 lines
15 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 pydantic import BaseModel
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}"',
},
)
class LetzshopExportRequest(BaseModel):
"""Request body for Letzshop export to pickup folder."""
include_inactive: bool = False
@router.post("/{vendor_identifier}/export/letzshop")
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:**
- 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
"""
import os
from pathlib import Path as FilePath
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
# 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"]
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)
exported_files.append({
"language": lang,
"filename": filename,
"path": str(filepath),
"size_bytes": os.path.getsize(filepath),
})
except Exception as e:
exported_files.append({
"language": lang,
"error": str(e),
})
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,
}