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:
2026-02-04 19:25:00 +01:00
parent 37942ae02b
commit 0583dd2cc4
29 changed files with 3643 additions and 650 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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
});

View File

@@ -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
# =========================================================================

View File

@@ -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

View File

@@ -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,

View File

@@ -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"

View File

@@ -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(

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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()

View File

@@ -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