refactor: move letzshop endpoints to marketplace module and add vendor service tests
Move letzshop-related functionality from tenancy to marketplace module: - Move admin letzshop routes to marketplace/routes/api/admin_letzshop.py - Move letzshop schemas to marketplace/schemas/letzshop.py - Remove letzshop code from tenancy module (admin_vendors, vendor_service) - Update model exports and imports Add comprehensive unit tests for vendor services: - test_company_service.py: Company management operations - test_platform_service.py: Platform management operations - test_vendor_domain_service.py: Vendor domain operations - test_vendor_team_service.py: Vendor team management Update module definitions: - billing, messaging, payments: Minor definition updates Add architecture proposals documentation: - Module dependency redesign session notes - Decouple modules implementation plan - Module decoupling proposal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,16 @@ Usage:
|
||||
# These imports are NOT re-exported, just ensure models are registered with SQLAlchemy
|
||||
# before the marketplace models are loaded.
|
||||
#
|
||||
# Relationship being resolved:
|
||||
# Relationships being resolved:
|
||||
# - LetzshopFulfillmentQueue.order -> "Order" (in orders module)
|
||||
# - MarketplaceImportJob.vendor -> "Vendor" (in tenancy module)
|
||||
# - MarketplaceImportJob.user -> "User" (in tenancy module)
|
||||
#
|
||||
# NOTE: This module owns the relationships to tenancy models (User, Vendor).
|
||||
# Core models should NOT have back-references to optional module models.
|
||||
from app.modules.orders.models import Order # noqa: F401
|
||||
from app.modules.tenancy.models.user import User # noqa: F401
|
||||
from app.modules.tenancy.models.vendor import Vendor # noqa: F401
|
||||
|
||||
from app.modules.marketplace.models.marketplace_product import (
|
||||
MarketplaceProduct,
|
||||
|
||||
@@ -95,7 +95,9 @@ class MarketplaceImportJob(Base, TimestampMixin):
|
||||
completed_at = Column(DateTime(timezone=True))
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
|
||||
# NOTE: No back_populates - optional modules own relationships to core models
|
||||
# Core models (User, Vendor) don't have back-references to optional modules
|
||||
vendor = relationship("Vendor")
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
errors = relationship(
|
||||
"MarketplaceImportError",
|
||||
|
||||
@@ -45,6 +45,8 @@ from app.modules.marketplace.schemas import (
|
||||
LetzshopCredentialsCreate,
|
||||
LetzshopCredentialsResponse,
|
||||
LetzshopCredentialsUpdate,
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportResponse,
|
||||
LetzshopHistoricalImportJobResponse,
|
||||
LetzshopHistoricalImportStartResponse,
|
||||
LetzshopJobItem,
|
||||
@@ -1520,3 +1522,197 @@ def create_vendor_from_letzshop(
|
||||
|
||||
except ValueError as e:
|
||||
raise ValidationException(str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Product Export
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_letzshop_router.get("/vendors/{vendor_id}/export")
|
||||
def export_vendor_products_letzshop(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
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: UserContext = 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.modules.marketplace.services.letzshop_export_service import letzshop_export_service
|
||||
|
||||
order_service = get_order_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@admin_letzshop_router.post(
|
||||
"/vendors/{vendor_id}/export",
|
||||
response_model=LetzshopExportResponse,
|
||||
)
|
||||
def export_vendor_products_letzshop_to_folder(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
request: LetzshopExportRequest = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = 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.modules.marketplace.services.letzshop_export_service import letzshop_export_service
|
||||
|
||||
order_service = get_order_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@ from app.modules.marketplace.schemas.letzshop import (
|
||||
LetzshopCachedVendorDetailResponse,
|
||||
LetzshopVendorDirectorySyncResponse,
|
||||
LetzshopCreateVendorFromCacheResponse,
|
||||
# Product Export
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportFileInfo,
|
||||
LetzshopExportResponse,
|
||||
)
|
||||
from app.modules.marketplace.schemas.onboarding import (
|
||||
# Step status
|
||||
@@ -184,6 +188,10 @@ __all__ = [
|
||||
"LetzshopCachedVendorDetailResponse",
|
||||
"LetzshopVendorDirectorySyncResponse",
|
||||
"LetzshopCreateVendorFromCacheResponse",
|
||||
# Letzshop - Product Export
|
||||
"LetzshopExportRequest",
|
||||
"LetzshopExportFileInfo",
|
||||
"LetzshopExportResponse",
|
||||
# Onboarding - Step status
|
||||
"StepStatus",
|
||||
"CompanyProfileStepStatus",
|
||||
|
||||
@@ -624,3 +624,41 @@ class LetzshopCreateVendorFromCacheResponse(BaseModel):
|
||||
message: str
|
||||
vendor: dict[str, Any] | None = None
|
||||
letzshop_vendor_slug: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Product Export Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class LetzshopExportRequest(BaseModel):
|
||||
"""Request body for Letzshop export to pickup folder."""
|
||||
|
||||
include_inactive: bool = Field(
|
||||
default=False,
|
||||
description="Include inactive products in export"
|
||||
)
|
||||
|
||||
|
||||
class LetzshopExportFileInfo(BaseModel):
|
||||
"""Info about an exported file."""
|
||||
|
||||
language: str
|
||||
filename: str | None = None
|
||||
path: str | None = None
|
||||
size_bytes: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class LetzshopExportResponse(BaseModel):
|
||||
"""Response from Letzshop export to folder."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
vendor_code: str
|
||||
export_directory: str
|
||||
files: list[LetzshopExportFileInfo]
|
||||
celery_task_id: str | None = None # Set when using Celery async export
|
||||
is_async: bool = Field(default=False, serialization_alias="async") # True when queued via Celery
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@@ -701,7 +701,7 @@ function adminMarketplaceLetzshop() {
|
||||
this.successMessage = '';
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/admin/vendors/${this.selectedVendor.id}/export/letzshop`, {
|
||||
const response = await apiClient.post(`/admin/letzshop/vendors/${this.selectedVendor.id}/export`, {
|
||||
include_inactive: this.exportIncludeInactive
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user