Files
orion/app/api/v1/admin/vendors.py
Samir Boulahtit eeafe6389f fix: resolve all remaining legacy import issues
- Update models/database/__init__.py to import from module locations
- Update models/schema/__init__.py to remove deleted modules
- Update models/__init__.py to import Inventory from module
- Remove duplicate AdminNotification from models/database/admin.py
- Fix monitoring module to import AdminNotification from messaging
- Update stats schema imports in admin/vendor API
- Update notification schema imports
- Add order_item_exception.py schema to orders module
- Fix app/api/v1/__init__.py to use storefront instead of shop
- Add cms_admin_pages import to main.py
- Fix password_reset_token imports
- Fix AdminNotification test imports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:21:29 +01:00

493 lines
17 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 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,
}