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:
@@ -77,11 +77,12 @@ billing_module = ModuleDefinition(
|
||||
code="billing",
|
||||
name="Billing & Subscriptions",
|
||||
description=(
|
||||
"Platform subscription management, vendor billing, and invoice history. "
|
||||
"Core subscription management, tier limits, vendor billing, and invoice history. "
|
||||
"Provides tier-based feature gating used throughout the platform. "
|
||||
"Uses the payments module for actual payment processing."
|
||||
),
|
||||
version="1.0.0",
|
||||
requires=["payments"], # Depends on payments module for payment processing
|
||||
requires=["payments"], # Depends on payments module (also core) for payment processing
|
||||
features=[
|
||||
"subscription_management", # Manage subscription tiers
|
||||
"billing_history", # View invoices and payment history
|
||||
@@ -200,7 +201,7 @@ billing_module = ModuleDefinition(
|
||||
),
|
||||
],
|
||||
},
|
||||
is_core=False, # Billing can be disabled (e.g., internal platforms)
|
||||
is_core=True, # Core module - tier limits and subscription management are fundamental
|
||||
# Context providers for dynamic page context
|
||||
context_providers={
|
||||
FrontendType.PLATFORM: _get_platform_context,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ def _get_vendor_router():
|
||||
messaging_module = ModuleDefinition(
|
||||
code="messaging",
|
||||
name="Messaging & Notifications",
|
||||
description="Internal messages, customer communication, and notifications.",
|
||||
description=(
|
||||
"Core email and notification system for user registration, password resets, "
|
||||
"team invitations, and system notifications. Required for basic platform operations."
|
||||
),
|
||||
version="1.0.0",
|
||||
features=[
|
||||
"customer_messaging", # Customer communication
|
||||
@@ -157,7 +160,7 @@ messaging_module = ModuleDefinition(
|
||||
),
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # Core module - email/notifications required for registration, password reset, etc.
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
# =========================================================================
|
||||
|
||||
@@ -38,8 +38,9 @@ payments_module = ModuleDefinition(
|
||||
code="payments",
|
||||
name="Payment Gateways",
|
||||
description=(
|
||||
"Payment gateway integrations for Stripe, PayPal, and bank transfers. "
|
||||
"Provides payment processing, refunds, and payment method management."
|
||||
"Core payment gateway integrations for Stripe, PayPal, and bank transfers. "
|
||||
"Provides payment processing, refunds, and payment method management. "
|
||||
"Required by billing module for subscription payments."
|
||||
),
|
||||
version="1.0.0",
|
||||
features=[
|
||||
@@ -80,7 +81,7 @@ payments_module = ModuleDefinition(
|
||||
"payment-methods", # Manage stored payment methods
|
||||
],
|
||||
},
|
||||
is_core=False,
|
||||
is_core=True, # Core module - required for billing and subscription management
|
||||
is_internal=False,
|
||||
# =========================================================================
|
||||
# Self-Contained Module Configuration
|
||||
|
||||
@@ -14,12 +14,12 @@ This is the canonical location for tenancy module models including:
|
||||
# These imports are NOT re-exported, just ensure models are registered with SQLAlchemy
|
||||
# before the tenancy models are loaded.
|
||||
#
|
||||
# Relationship chain being resolved:
|
||||
# Relationship being resolved:
|
||||
# - Platform.admin_menu_configs -> "AdminMenuConfig" (in core module)
|
||||
# - User.marketplace_import_jobs -> "MarketplaceImportJob" (in marketplace module)
|
||||
# - Vendor.marketplace_import_jobs -> "MarketplaceImportJob" (in marketplace module)
|
||||
#
|
||||
# NOTE: MarketplaceImportJob relationships have been moved to the marketplace module.
|
||||
# Optional modules own their relationships to core models, not vice versa.
|
||||
from app.modules.core.models import AdminMenuConfig # noqa: F401
|
||||
from app.modules.marketplace.models.marketplace_import_job import MarketplaceImportJob # noqa: F401
|
||||
|
||||
from app.modules.tenancy.models.admin import (
|
||||
AdminAuditLog,
|
||||
|
||||
@@ -56,9 +56,8 @@ class User(Base, TimestampMixin):
|
||||
preferred_language = Column(String(5), nullable=True)
|
||||
|
||||
# Relationships
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="user"
|
||||
)
|
||||
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
|
||||
# Use: MarketplaceImportJob.query.filter_by(user_id=user.id) instead
|
||||
owned_companies = relationship("Company", back_populates="owner")
|
||||
vendor_memberships = relationship(
|
||||
"VendorUser", foreign_keys="[VendorUser.user_id]", back_populates="user"
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Vendor model representing entities that sell products or services.
|
||||
|
||||
This module defines the Vendor model along with its relationships to
|
||||
other models such as User (owner), Product, Customer, Order, and MarketplaceImportJob.
|
||||
other models such as User (owner), Product, Customer, and Order.
|
||||
|
||||
Note: MarketplaceImportJob relationships are owned by the marketplace module.
|
||||
"""
|
||||
|
||||
import enum
|
||||
@@ -143,9 +145,8 @@ class Vendor(Base, TimestampMixin):
|
||||
orders = relationship(
|
||||
"Order", back_populates="vendor"
|
||||
) # Relationship with Order model for orders placed by this vendor
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="vendor"
|
||||
) # Relationship with MarketplaceImportJob model for import jobs related to this vendor
|
||||
# NOTE: marketplace_import_jobs relationship removed - owned by marketplace module
|
||||
# Use: MarketplaceImportJob.query.filter_by(vendor_id=vendor.id) instead
|
||||
|
||||
# Letzshop integration credentials (one-to-one)
|
||||
letzshop_credentials = relationship(
|
||||
|
||||
@@ -17,17 +17,14 @@ from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.exceptions import ConfirmationRequiredException
|
||||
from app.modules.tenancy.services.admin_service import admin_service
|
||||
from app.modules.analytics.services.stats_service import stats_service
|
||||
from app.modules.tenancy.services.vendor_service import vendor_service
|
||||
from models.schema.auth import UserContext
|
||||
from app.modules.analytics.schemas import VendorStatsResponse
|
||||
from app.modules.tenancy.schemas.vendor import (
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportResponse,
|
||||
VendorCreate,
|
||||
VendorCreateResponse,
|
||||
VendorDetailResponse,
|
||||
VendorListResponse,
|
||||
VendorStatsResponse,
|
||||
VendorUpdate,
|
||||
)
|
||||
|
||||
@@ -109,14 +106,22 @@ def get_vendor_statistics_endpoint(
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get vendor statistics for admin dashboard (Admin only)."""
|
||||
stats = stats_service.get_vendor_statistics(db)
|
||||
from app.modules.tenancy.models import Vendor
|
||||
|
||||
# Query vendor statistics directly to avoid analytics module dependency
|
||||
total = db.query(Vendor).count()
|
||||
verified = db.query(Vendor).filter(Vendor.is_verified == True).count()
|
||||
active = db.query(Vendor).filter(Vendor.is_active == True).count()
|
||||
inactive = total - active
|
||||
pending = db.query(Vendor).filter(
|
||||
Vendor.is_active == True, Vendor.is_verified == False
|
||||
).count()
|
||||
|
||||
# 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)),
|
||||
total=total,
|
||||
verified=verified,
|
||||
pending=pending,
|
||||
inactive=inactive,
|
||||
)
|
||||
|
||||
|
||||
@@ -310,183 +315,3 @@ def delete_vendor(
|
||||
message = admin_service.delete_vendor(db, vendor.id)
|
||||
db.commit()
|
||||
return {"message": message}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LETZSHOP EXPORT
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_vendors_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: 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
|
||||
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@admin_vendors_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: 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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ from app.modules.tenancy.schemas.company import (
|
||||
|
||||
# Vendor schemas
|
||||
from app.modules.tenancy.schemas.vendor import (
|
||||
LetzshopExportFileInfo,
|
||||
LetzshopExportRequest,
|
||||
LetzshopExportResponse,
|
||||
VendorCreate,
|
||||
VendorCreateResponse,
|
||||
VendorDetailResponse,
|
||||
@@ -125,9 +122,6 @@ __all__ = [
|
||||
"CompanyTransferOwnershipResponse",
|
||||
"CompanyUpdate",
|
||||
# Vendor
|
||||
"LetzshopExportFileInfo",
|
||||
"LetzshopExportRequest",
|
||||
"LetzshopExportResponse",
|
||||
"VendorCreate",
|
||||
"VendorCreateResponse",
|
||||
"VendorDetailResponse",
|
||||
|
||||
@@ -312,40 +312,21 @@ class VendorSummary(BaseModel):
|
||||
# Ownership transfer is now handled at the Company level.
|
||||
# See models/schema/company.py for CompanyTransferOwnership and CompanyTransferOwnershipResponse.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LETZSHOP EXPORT SCHEMAS
|
||||
# ============================================================================
|
||||
# NOTE: Letzshop export schemas have been moved to app.modules.marketplace.schemas.letzshop
|
||||
# See LetzshopExportRequest, LetzshopExportFileInfo, LetzshopExportResponse
|
||||
|
||||
|
||||
class LetzshopExportRequest(BaseModel):
|
||||
"""Request body for Letzshop export to pickup folder."""
|
||||
# Re-export VendorStatsResponse from core for convenience
|
||||
# This allows tenancy routes to use this schema without importing from core directly
|
||||
from app.modules.core.schemas.dashboard import VendorStatsResponse
|
||||
|
||||
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 = {"populate_by_name": True}
|
||||
__all__ = [
|
||||
"VendorCreate",
|
||||
"VendorUpdate",
|
||||
"VendorResponse",
|
||||
"VendorDetailResponse",
|
||||
"VendorCreateResponse",
|
||||
"VendorListResponse",
|
||||
"VendorSummary",
|
||||
"VendorStatsResponse",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# app/modules/tenancy/services/admin_service.py
|
||||
"""
|
||||
Admin service for managing users, vendors, and import jobs.
|
||||
Admin service for managing users and vendors.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- User management and status control
|
||||
- Vendor creation with owner user generation
|
||||
- Vendor verification and activation
|
||||
- Marketplace import job monitoring
|
||||
- Platform statistics
|
||||
|
||||
Note: Marketplace import job monitoring has been moved to the marketplace module.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -33,11 +34,9 @@ from app.modules.tenancy.exceptions import (
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
from app.modules.tenancy.models import Company
|
||||
from app.modules.marketplace.models import MarketplaceImportJob
|
||||
from app.modules.tenancy.models import Platform
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Role, Vendor
|
||||
from app.modules.marketplace.schemas import MarketplaceImportJobResponse
|
||||
from app.modules.tenancy.schemas.vendor import VendorCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -703,48 +702,8 @@ class AdminService:
|
||||
# NOTE: Vendor ownership transfer is now handled at the Company level.
|
||||
# Use company_service.transfer_ownership() instead.
|
||||
|
||||
# ============================================================================
|
||||
# MARKETPLACE IMPORT JOBS
|
||||
# ============================================================================
|
||||
|
||||
def get_marketplace_import_jobs(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: str | None = None,
|
||||
vendor_name: str | None = None,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[MarketplaceImportJobResponse]:
|
||||
"""Get filtered and paginated marketplace import jobs."""
|
||||
try:
|
||||
query = db.query(MarketplaceImportJob)
|
||||
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
if vendor_name:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%")
|
||||
)
|
||||
if status:
|
||||
query = query.filter(MarketplaceImportJob.status == status)
|
||||
|
||||
jobs = (
|
||||
query.order_by(MarketplaceImportJob.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [self._convert_job_to_response(job) for job in jobs]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve marketplace import jobs: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="get_marketplace_import_jobs", reason="Database query failed"
|
||||
)
|
||||
# NOTE: Marketplace import job operations have been moved to the marketplace module.
|
||||
# Use app.modules.marketplace routes for import job management.
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS
|
||||
@@ -773,30 +732,7 @@ class AdminService:
|
||||
logger.error(f"Failed to get recent vendors: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_recent_import_jobs(self, db: Session, limit: int = 10) -> list[dict]:
|
||||
"""Get recent marketplace import jobs."""
|
||||
try:
|
||||
jobs = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.order_by(MarketplaceImportJob.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": j.id,
|
||||
"marketplace": j.marketplace,
|
||||
"vendor_name": j.vendor_name,
|
||||
"status": j.status,
|
||||
"total_processed": j.total_processed or 0,
|
||||
"created_at": j.created_at,
|
||||
}
|
||||
for j in jobs
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent import jobs: {str(e)}")
|
||||
return []
|
||||
# NOTE: get_recent_import_jobs has been moved to the marketplace module
|
||||
|
||||
# ============================================================================
|
||||
# PRIVATE HELPER METHODS
|
||||
@@ -868,28 +804,6 @@ class AdminService:
|
||||
)
|
||||
db.add(role)
|
||||
|
||||
def _convert_job_to_response(
|
||||
self, job: MarketplaceImportJob
|
||||
) -> MarketplaceImportJobResponse:
|
||||
"""Convert database model to response schema."""
|
||||
return MarketplaceImportJobResponse(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
marketplace=job.marketplace,
|
||||
source_url=job.source_url,
|
||||
vendor_id=job.vendor.id if job.vendor else None,
|
||||
vendor_code=job.vendor.vendor_code if job.vendor else None,
|
||||
vendor_name=job.vendor.name if job.vendor else None,
|
||||
imported=job.imported_count or 0,
|
||||
updated=job.updated_count or 0,
|
||||
total_processed=job.total_processed or 0,
|
||||
error_count=job.error_count or 0,
|
||||
error_message=job.error_message,
|
||||
created_at=job.created_at,
|
||||
started_at=job.started_at,
|
||||
completed_at=job.completed_at,
|
||||
)
|
||||
|
||||
|
||||
# Create service instance
|
||||
admin_service = AdminService()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# app/modules/tenancy/services/vendor_service.py
|
||||
"""
|
||||
Vendor service for managing vendor operations and product catalog.
|
||||
Vendor service for managing vendor operations.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- Vendor creation and management
|
||||
- Vendor access control and validation
|
||||
- Vendor product catalog operations
|
||||
- Vendor filtering and search
|
||||
|
||||
Note: Product catalog operations have been moved to app.modules.catalog.services.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,19 +16,14 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.catalog.exceptions import ProductAlreadyExistsException
|
||||
from app.modules.marketplace.exceptions import MarketplaceProductNotFoundException
|
||||
from app.modules.tenancy.exceptions import (
|
||||
InvalidVendorDataException,
|
||||
UnauthorizedVendorAccessException,
|
||||
VendorAlreadyExistsException,
|
||||
VendorNotFoundException,
|
||||
)
|
||||
from app.modules.marketplace.models import MarketplaceProduct
|
||||
from app.modules.catalog.models import Product
|
||||
from app.modules.tenancy.models import User
|
||||
from app.modules.tenancy.models import Vendor
|
||||
from app.modules.catalog.schemas import ProductCreate
|
||||
from app.modules.tenancy.schemas.vendor import VendorCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -443,110 +439,10 @@ class VendorService:
|
||||
logger.info(f"Vendor {vendor.vendor_code} set to {status}")
|
||||
return vendor, f"Vendor {vendor.vendor_code} is now {status}"
|
||||
|
||||
def add_product_to_catalog(
|
||||
self, db: Session, vendor: Vendor, product: ProductCreate
|
||||
) -> Product:
|
||||
"""
|
||||
Add existing product to vendor catalog with vendor -specific settings.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor : Vendor to add product to
|
||||
product: Vendor product data
|
||||
|
||||
Returns:
|
||||
Created Product object
|
||||
|
||||
Raises:
|
||||
MarketplaceProductNotFoundException: If product not found
|
||||
ProductAlreadyExistsException: If product already in vendor
|
||||
"""
|
||||
try:
|
||||
# Check if product exists
|
||||
marketplace_product = self._get_product_by_id_or_raise(
|
||||
db, product.marketplace_product_id
|
||||
)
|
||||
|
||||
# Check if product already in vendor
|
||||
if self._product_in_catalog(db, vendor.id, marketplace_product.id):
|
||||
raise ProductAlreadyExistsException(
|
||||
vendor.vendor_code, product.marketplace_product_id
|
||||
)
|
||||
|
||||
# Create vendor-product association
|
||||
new_product = Product(
|
||||
vendor_id=vendor.id,
|
||||
marketplace_product_id=marketplace_product.id,
|
||||
**product.model_dump(exclude={"marketplace_product_id"}),
|
||||
)
|
||||
|
||||
db.add(new_product)
|
||||
db.flush() # Get ID without committing - endpoint handles commit
|
||||
|
||||
logger.info(
|
||||
f"MarketplaceProduct {product.marketplace_product_id} added to vendor {vendor.vendor_code}"
|
||||
)
|
||||
return new_product
|
||||
|
||||
except (MarketplaceProductNotFoundException, ProductAlreadyExistsException):
|
||||
raise # Re-raise custom exceptions - endpoint handles rollback
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding product to vendor : {str(e)}")
|
||||
raise ValidationException("Failed to add product to vendor ")
|
||||
|
||||
def get_products(
|
||||
self,
|
||||
db: Session,
|
||||
vendor: Vendor,
|
||||
current_user: User,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
active_only: bool = True,
|
||||
featured_only: bool = False,
|
||||
) -> tuple[list[Product], int]:
|
||||
"""
|
||||
Get products in vendor catalog with filtering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor : Vendor to get products from
|
||||
current_user: Current user requesting products
|
||||
skip: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
active_only: Filter for active products only
|
||||
featured_only: Filter for featured products only
|
||||
|
||||
Returns:
|
||||
Tuple of (products_list, total_count)
|
||||
|
||||
Raises:
|
||||
UnauthorizedVendorAccessException: If vendor access denied
|
||||
"""
|
||||
try:
|
||||
# Check access permissions
|
||||
if not self._can_access_vendor(vendor, current_user):
|
||||
raise UnauthorizedVendorAccessException(
|
||||
vendor.vendor_code, current_user.id
|
||||
)
|
||||
|
||||
# Query vendor products
|
||||
query = db.query(Product).filter(Product.vendor_id == vendor.id)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(Product.is_active == True)
|
||||
if featured_only:
|
||||
query = query.filter(Product.is_featured == True)
|
||||
|
||||
total = query.count()
|
||||
products = query.offset(skip).limit(limit).all()
|
||||
|
||||
return products, total
|
||||
|
||||
except UnauthorizedVendorAccessException:
|
||||
raise # Re-raise custom exceptions
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting vendor products: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve vendor products")
|
||||
# NOTE: Product catalog operations have been moved to catalog module.
|
||||
# Use app.modules.catalog.services.product_service instead.
|
||||
# - add_product_to_catalog -> product_service.create_product
|
||||
# - get_products -> product_service.get_vendor_products
|
||||
|
||||
# Private helper methods
|
||||
def _vendor_code_exists(self, db: Session, vendor_code: str) -> bool:
|
||||
@@ -558,33 +454,6 @@ class VendorService:
|
||||
is not None
|
||||
)
|
||||
|
||||
def _get_product_by_id_or_raise(
|
||||
self, db: Session, marketplace_product_id: int
|
||||
) -> MarketplaceProduct:
|
||||
"""Get marketplace product by database ID or raise exception."""
|
||||
product = (
|
||||
db.query(MarketplaceProduct)
|
||||
.filter(MarketplaceProduct.id == marketplace_product_id)
|
||||
.first()
|
||||
)
|
||||
if not product:
|
||||
raise MarketplaceProductNotFoundException(str(marketplace_product_id))
|
||||
return product
|
||||
|
||||
def _product_in_catalog(
|
||||
self, db: Session, vendor_id: int, marketplace_product_id: int
|
||||
) -> bool:
|
||||
"""Check if product is already in vendor."""
|
||||
return (
|
||||
db.query(Product)
|
||||
.filter(
|
||||
Product.vendor_id == vendor_id,
|
||||
Product.marketplace_product_id == marketplace_product_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
def _can_access_vendor(self, vendor: Vendor, user: User) -> bool:
|
||||
"""Check if user can access vendor."""
|
||||
# Admins can always access
|
||||
|
||||
Reference in New Issue
Block a user