refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,45 +2,35 @@
|
||||
"""
|
||||
Marketplace module services.
|
||||
|
||||
Re-exports Letzshop and marketplace services from their current locations.
|
||||
Services remain in app/services/ for now to avoid breaking existing imports.
|
||||
|
||||
Usage:
|
||||
from app.modules.marketplace.services import (
|
||||
letzshop_export_service,
|
||||
marketplace_import_job_service,
|
||||
marketplace_product_service,
|
||||
)
|
||||
from app.modules.marketplace.services.letzshop import (
|
||||
LetzshopClient,
|
||||
LetzshopCredentialsService,
|
||||
LetzshopOrderService,
|
||||
LetzshopVendorSyncService,
|
||||
)
|
||||
This module contains the canonical implementations of marketplace-related services.
|
||||
"""
|
||||
|
||||
# Re-export from existing locations for convenience
|
||||
from app.services.letzshop_export_service import (
|
||||
# Main marketplace services
|
||||
from app.modules.marketplace.services.letzshop_export_service import (
|
||||
LetzshopExportService,
|
||||
letzshop_export_service,
|
||||
)
|
||||
from app.services.marketplace_import_job_service import (
|
||||
from app.modules.marketplace.services.marketplace_import_job_service import (
|
||||
MarketplaceImportJobService,
|
||||
marketplace_import_job_service,
|
||||
)
|
||||
from app.services.marketplace_product_service import (
|
||||
from app.modules.marketplace.services.marketplace_product_service import (
|
||||
MarketplaceProductService,
|
||||
marketplace_product_service,
|
||||
)
|
||||
|
||||
# Letzshop submodule re-exports
|
||||
from app.services.letzshop import (
|
||||
# Letzshop submodule services
|
||||
from app.modules.marketplace.services.letzshop import (
|
||||
LetzshopClient,
|
||||
LetzshopClientError,
|
||||
)
|
||||
from app.services.letzshop.credentials_service import LetzshopCredentialsService
|
||||
from app.services.letzshop.order_service import LetzshopOrderService
|
||||
from app.services.letzshop.vendor_sync_service import LetzshopVendorSyncService
|
||||
from app.modules.marketplace.services.letzshop.credentials_service import (
|
||||
LetzshopCredentialsService,
|
||||
)
|
||||
from app.modules.marketplace.services.letzshop.order_service import LetzshopOrderService
|
||||
from app.modules.marketplace.services.letzshop.vendor_sync_service import (
|
||||
LetzshopVendorSyncService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Export service
|
||||
|
||||
53
app/modules/marketplace/services/letzshop/__init__.py
Normal file
53
app/modules/marketplace/services/letzshop/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# app/modules/marketplace/services/letzshop/__init__.py
|
||||
"""
|
||||
Letzshop marketplace integration services.
|
||||
|
||||
Provides:
|
||||
- GraphQL client for API communication
|
||||
- Credential management service
|
||||
- Order import service
|
||||
- Fulfillment sync service
|
||||
- Vendor directory sync service
|
||||
"""
|
||||
|
||||
from .client_service import (
|
||||
LetzshopAPIError,
|
||||
LetzshopAuthError,
|
||||
LetzshopClient,
|
||||
LetzshopClientError,
|
||||
LetzshopConnectionError,
|
||||
)
|
||||
from .credentials_service import (
|
||||
CredentialsError,
|
||||
CredentialsNotFoundError,
|
||||
LetzshopCredentialsService,
|
||||
)
|
||||
from .order_service import (
|
||||
LetzshopOrderService,
|
||||
OrderNotFoundError,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from .vendor_sync_service import (
|
||||
LetzshopVendorSyncService,
|
||||
get_vendor_sync_service,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"LetzshopClient",
|
||||
"LetzshopClientError",
|
||||
"LetzshopAuthError",
|
||||
"LetzshopAPIError",
|
||||
"LetzshopConnectionError",
|
||||
# Credentials
|
||||
"LetzshopCredentialsService",
|
||||
"CredentialsError",
|
||||
"CredentialsNotFoundError",
|
||||
# Order Service
|
||||
"LetzshopOrderService",
|
||||
"OrderNotFoundError",
|
||||
"VendorNotFoundError",
|
||||
# Vendor Sync Service
|
||||
"LetzshopVendorSyncService",
|
||||
"get_vendor_sync_service",
|
||||
]
|
||||
1015
app/modules/marketplace/services/letzshop/client_service.py
Normal file
1015
app/modules/marketplace/services/letzshop/client_service.py
Normal file
File diff suppressed because it is too large
Load Diff
400
app/modules/marketplace/services/letzshop/credentials_service.py
Normal file
400
app/modules/marketplace/services/letzshop/credentials_service.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# app/services/letzshop/credentials_service.py
|
||||
"""
|
||||
Letzshop credentials management service.
|
||||
|
||||
Handles secure storage and retrieval of per-vendor Letzshop API credentials.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.encryption import decrypt_value, encrypt_value, mask_api_key
|
||||
from models.database.letzshop import VendorLetzshopCredentials
|
||||
|
||||
from .client_service import LetzshopClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Letzshop GraphQL endpoint
|
||||
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
|
||||
|
||||
|
||||
class CredentialsError(Exception):
|
||||
"""Base exception for credentials errors."""
|
||||
|
||||
|
||||
class CredentialsNotFoundError(CredentialsError):
|
||||
"""Raised when credentials are not found for a vendor."""
|
||||
|
||||
|
||||
class LetzshopCredentialsService:
|
||||
"""
|
||||
Service for managing Letzshop API credentials.
|
||||
|
||||
Provides secure storage and retrieval of encrypted API keys,
|
||||
connection testing, and sync status updates.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize the credentials service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# ========================================================================
|
||||
# CRUD Operations
|
||||
# ========================================================================
|
||||
|
||||
def get_credentials(self, vendor_id: int) -> VendorLetzshopCredentials | None:
|
||||
"""
|
||||
Get Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
VendorLetzshopCredentials or None if not found.
|
||||
"""
|
||||
return (
|
||||
self.db.query(VendorLetzshopCredentials)
|
||||
.filter(VendorLetzshopCredentials.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_credentials_or_raise(self, vendor_id: int) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Get Letzshop credentials for a vendor or raise an exception.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
VendorLetzshopCredentials.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
raise CredentialsNotFoundError(
|
||||
f"Letzshop credentials not found for vendor {vendor_id}"
|
||||
)
|
||||
return credentials
|
||||
|
||||
def create_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool = False,
|
||||
sync_interval_minutes: int = 15,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Create Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: The Letzshop API key (will be encrypted).
|
||||
api_endpoint: Custom API endpoint (optional).
|
||||
auto_sync_enabled: Whether to enable automatic sync.
|
||||
sync_interval_minutes: Sync interval in minutes.
|
||||
|
||||
Returns:
|
||||
Created VendorLetzshopCredentials.
|
||||
"""
|
||||
# Encrypt the API key
|
||||
encrypted_key = encrypt_value(api_key)
|
||||
|
||||
credentials = VendorLetzshopCredentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key_encrypted=encrypted_key,
|
||||
api_endpoint=api_endpoint or DEFAULT_ENDPOINT,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
self.db.add(credentials)
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Created Letzshop credentials for vendor {vendor_id}")
|
||||
return credentials
|
||||
|
||||
def update_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str | None = None,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool | None = None,
|
||||
sync_interval_minutes: int | None = None,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Update Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: New API key (optional, will be encrypted if provided).
|
||||
api_endpoint: New API endpoint (optional).
|
||||
auto_sync_enabled: New auto-sync setting (optional).
|
||||
sync_interval_minutes: New sync interval (optional).
|
||||
|
||||
Returns:
|
||||
Updated VendorLetzshopCredentials.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
|
||||
if api_key is not None:
|
||||
credentials.api_key_encrypted = encrypt_value(api_key)
|
||||
if api_endpoint is not None:
|
||||
credentials.api_endpoint = api_endpoint
|
||||
if auto_sync_enabled is not None:
|
||||
credentials.auto_sync_enabled = auto_sync_enabled
|
||||
if sync_interval_minutes is not None:
|
||||
credentials.sync_interval_minutes = sync_interval_minutes
|
||||
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Updated Letzshop credentials for vendor {vendor_id}")
|
||||
return credentials
|
||||
|
||||
def delete_credentials(self, vendor_id: int) -> bool:
|
||||
"""
|
||||
Delete Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
return False
|
||||
|
||||
self.db.delete(credentials)
|
||||
self.db.flush()
|
||||
|
||||
logger.info(f"Deleted Letzshop credentials for vendor {vendor_id}")
|
||||
return True
|
||||
|
||||
def upsert_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool = False,
|
||||
sync_interval_minutes: int = 15,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Create or update Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: The Letzshop API key (will be encrypted).
|
||||
api_endpoint: Custom API endpoint (optional).
|
||||
auto_sync_enabled: Whether to enable automatic sync.
|
||||
sync_interval_minutes: Sync interval in minutes.
|
||||
|
||||
Returns:
|
||||
Created or updated VendorLetzshopCredentials.
|
||||
"""
|
||||
existing = self.get_credentials(vendor_id)
|
||||
|
||||
if existing:
|
||||
return self.update_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=api_key,
|
||||
api_endpoint=api_endpoint,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
return self.create_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=api_key,
|
||||
api_endpoint=api_endpoint,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Key Decryption and Client Creation
|
||||
# ========================================================================
|
||||
|
||||
def get_decrypted_api_key(self, vendor_id: int) -> str:
|
||||
"""
|
||||
Get the decrypted API key for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Decrypted API key.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
return decrypt_value(credentials.api_key_encrypted)
|
||||
|
||||
def get_masked_api_key(self, vendor_id: int) -> str:
|
||||
"""
|
||||
Get a masked version of the API key for display.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Masked API key (e.g., "sk-a***************").
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
api_key = self.get_decrypted_api_key(vendor_id)
|
||||
return mask_api_key(api_key)
|
||||
|
||||
def create_client(self, vendor_id: int) -> LetzshopClient:
|
||||
"""
|
||||
Create a Letzshop client for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Configured LetzshopClient.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
api_key = decrypt_value(credentials.api_key_encrypted)
|
||||
|
||||
return LetzshopClient(
|
||||
api_key=api_key,
|
||||
endpoint=credentials.api_endpoint,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Connection Testing
|
||||
# ========================================================================
|
||||
|
||||
def test_connection(self, vendor_id: int) -> tuple[bool, float | None, str | None]:
|
||||
"""
|
||||
Test the connection for a vendor's credentials.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_time_ms, error_message).
|
||||
"""
|
||||
try:
|
||||
with self.create_client(vendor_id) as client:
|
||||
return client.test_connection()
|
||||
except CredentialsNotFoundError:
|
||||
return False, None, "Letzshop credentials not configured"
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed for vendor {vendor_id}: {e}")
|
||||
return False, None, str(e)
|
||||
|
||||
def test_api_key(
|
||||
self,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
) -> tuple[bool, float | None, str | None]:
|
||||
"""
|
||||
Test an API key without saving it.
|
||||
|
||||
Args:
|
||||
api_key: The API key to test.
|
||||
api_endpoint: Optional custom endpoint.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_time_ms, error_message).
|
||||
"""
|
||||
try:
|
||||
with LetzshopClient(
|
||||
api_key=api_key,
|
||||
endpoint=api_endpoint or DEFAULT_ENDPOINT,
|
||||
) as client:
|
||||
return client.test_connection()
|
||||
except Exception as e:
|
||||
logger.error(f"API key test failed: {e}")
|
||||
return False, None, str(e)
|
||||
|
||||
# ========================================================================
|
||||
# Sync Status Updates
|
||||
# ========================================================================
|
||||
|
||||
def update_sync_status(
|
||||
self,
|
||||
vendor_id: int,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
) -> VendorLetzshopCredentials | None:
|
||||
"""
|
||||
Update the last sync status for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
status: Sync status (success, failed, partial).
|
||||
error: Error message if sync failed.
|
||||
|
||||
Returns:
|
||||
Updated credentials or None if not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
credentials.last_sync_at = datetime.now(UTC)
|
||||
credentials.last_sync_status = status
|
||||
credentials.last_sync_error = error
|
||||
|
||||
self.db.flush()
|
||||
|
||||
return credentials
|
||||
|
||||
# ========================================================================
|
||||
# Status Helpers
|
||||
# ========================================================================
|
||||
|
||||
def is_configured(self, vendor_id: int) -> bool:
|
||||
"""Check if Letzshop is configured for a vendor."""
|
||||
return self.get_credentials(vendor_id) is not None
|
||||
|
||||
def get_status(self, vendor_id: int) -> dict:
|
||||
"""
|
||||
Get the Letzshop integration status for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Status dictionary with configuration and sync info.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
|
||||
if credentials is None:
|
||||
return {
|
||||
"is_configured": False,
|
||||
"is_connected": False,
|
||||
"last_sync_at": None,
|
||||
"last_sync_status": None,
|
||||
"auto_sync_enabled": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"is_configured": True,
|
||||
"is_connected": credentials.last_sync_status == "success",
|
||||
"last_sync_at": credentials.last_sync_at,
|
||||
"last_sync_status": credentials.last_sync_status,
|
||||
"auto_sync_enabled": credentials.auto_sync_enabled,
|
||||
}
|
||||
1136
app/modules/marketplace/services/letzshop/order_service.py
Normal file
1136
app/modules/marketplace/services/letzshop/order_service.py
Normal file
File diff suppressed because it is too large
Load Diff
521
app/modules/marketplace/services/letzshop/vendor_sync_service.py
Normal file
521
app/modules/marketplace/services/letzshop/vendor_sync_service.py
Normal file
@@ -0,0 +1,521 @@
|
||||
# app/services/letzshop/vendor_sync_service.py
|
||||
"""
|
||||
Service for syncing Letzshop vendor directory to local cache.
|
||||
|
||||
Fetches vendor data from Letzshop's public GraphQL API and stores it
|
||||
in the letzshop_vendor_cache table for fast lookups during signup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.letzshop.client_service import LetzshopClient
|
||||
from models.database.letzshop import LetzshopVendorCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LetzshopVendorSyncService:
|
||||
"""
|
||||
Service for syncing Letzshop vendor directory.
|
||||
|
||||
Usage:
|
||||
service = LetzshopVendorSyncService(db)
|
||||
stats = service.sync_all_vendors()
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize the sync service."""
|
||||
self.db = db
|
||||
|
||||
def sync_all_vendors(
|
||||
self,
|
||||
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||
max_pages: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Sync all vendors from Letzshop to local cache.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback(page, fetched, total) for progress.
|
||||
|
||||
Returns:
|
||||
Dictionary with sync statistics.
|
||||
"""
|
||||
stats = {
|
||||
"started_at": datetime.now(UTC),
|
||||
"total_fetched": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": 0,
|
||||
"error_details": [],
|
||||
}
|
||||
|
||||
logger.info("Starting Letzshop vendor directory sync...")
|
||||
|
||||
# Create client (no API key needed for public vendor data)
|
||||
client = LetzshopClient(api_key="")
|
||||
|
||||
try:
|
||||
# Fetch all vendors
|
||||
vendors = client.get_all_vendors_paginated(
|
||||
page_size=50,
|
||||
max_pages=max_pages,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
stats["total_fetched"] = len(vendors)
|
||||
logger.info(f"Fetched {len(vendors)} vendors from Letzshop")
|
||||
|
||||
# Process each vendor
|
||||
for vendor_data in vendors:
|
||||
try:
|
||||
result = self._upsert_vendor(vendor_data)
|
||||
if result == "created":
|
||||
stats["created"] += 1
|
||||
elif result == "updated":
|
||||
stats["updated"] += 1
|
||||
except Exception as e:
|
||||
stats["errors"] += 1
|
||||
error_info = {
|
||||
"vendor_id": vendor_data.get("id"),
|
||||
"slug": vendor_data.get("slug"),
|
||||
"error": str(e),
|
||||
}
|
||||
stats["error_details"].append(error_info)
|
||||
logger.error(f"Error processing vendor {vendor_data.get('slug')}: {e}")
|
||||
|
||||
# Commit all changes
|
||||
self.db.commit()
|
||||
logger.info(
|
||||
f"Sync complete: {stats['created']} created, "
|
||||
f"{stats['updated']} updated, {stats['errors']} errors"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Vendor sync failed: {e}")
|
||||
stats["error"] = str(e)
|
||||
raise
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
stats["completed_at"] = datetime.now(UTC)
|
||||
stats["duration_seconds"] = (
|
||||
stats["completed_at"] - stats["started_at"]
|
||||
).total_seconds()
|
||||
|
||||
return stats
|
||||
|
||||
def _upsert_vendor(self, vendor_data: dict[str, Any]) -> str:
|
||||
"""
|
||||
Insert or update a vendor in the cache.
|
||||
|
||||
Args:
|
||||
vendor_data: Raw vendor data from Letzshop API.
|
||||
|
||||
Returns:
|
||||
"created" or "updated" indicating the operation performed.
|
||||
"""
|
||||
letzshop_id = vendor_data.get("id")
|
||||
slug = vendor_data.get("slug")
|
||||
|
||||
if not letzshop_id or not slug:
|
||||
raise ValueError("Vendor missing required id or slug")
|
||||
|
||||
# Parse the vendor data
|
||||
parsed = self._parse_vendor_data(vendor_data)
|
||||
|
||||
# Check if exists
|
||||
existing = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.letzshop_id == letzshop_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing record (preserve claimed status)
|
||||
for key, value in parsed.items():
|
||||
if key not in ("claimed_by_vendor_id", "claimed_at"):
|
||||
setattr(existing, key, value)
|
||||
existing.last_synced_at = datetime.now(UTC)
|
||||
return "updated"
|
||||
else:
|
||||
# Create new record
|
||||
cache_entry = LetzshopVendorCache(
|
||||
**parsed,
|
||||
last_synced_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(cache_entry)
|
||||
return "created"
|
||||
|
||||
def _parse_vendor_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Parse raw Letzshop vendor data into cache model fields.
|
||||
|
||||
Args:
|
||||
data: Raw vendor data from Letzshop API.
|
||||
|
||||
Returns:
|
||||
Dictionary of parsed fields for LetzshopVendorCache.
|
||||
"""
|
||||
# Extract location
|
||||
location = data.get("location") or {}
|
||||
country = location.get("country") or {}
|
||||
|
||||
# Extract descriptions
|
||||
description = data.get("description") or {}
|
||||
|
||||
# Extract opening hours
|
||||
opening_hours = data.get("openingHours") or {}
|
||||
|
||||
# Extract categories (list of translated name objects)
|
||||
categories = []
|
||||
for cat in data.get("vendorCategories") or []:
|
||||
cat_name = cat.get("name") or {}
|
||||
# Prefer English, fallback to French or German
|
||||
name = cat_name.get("en") or cat_name.get("fr") or cat_name.get("de")
|
||||
if name:
|
||||
categories.append(name)
|
||||
|
||||
# Extract social media URLs
|
||||
social_links = []
|
||||
for link in data.get("socialMediaLinks") or []:
|
||||
url = link.get("url")
|
||||
if url:
|
||||
social_links.append(url)
|
||||
|
||||
# Extract background image
|
||||
bg_image = data.get("backgroundImage") or {}
|
||||
|
||||
return {
|
||||
"letzshop_id": data.get("id"),
|
||||
"slug": data.get("slug"),
|
||||
"name": data.get("name"),
|
||||
"company_name": data.get("companyName") or data.get("legalName"),
|
||||
"is_active": data.get("active", True),
|
||||
# Descriptions
|
||||
"description_en": description.get("en"),
|
||||
"description_fr": description.get("fr"),
|
||||
"description_de": description.get("de"),
|
||||
# Contact
|
||||
"email": data.get("email"),
|
||||
"phone": data.get("phone"),
|
||||
"fax": data.get("fax"),
|
||||
"website": data.get("homepage"),
|
||||
# Location
|
||||
"street": location.get("street"),
|
||||
"street_number": location.get("number"),
|
||||
"city": location.get("city"),
|
||||
"zipcode": location.get("zipcode"),
|
||||
"country_iso": country.get("iso", "LU"),
|
||||
"latitude": str(data.get("lat")) if data.get("lat") else None,
|
||||
"longitude": str(data.get("lng")) if data.get("lng") else None,
|
||||
# Categories and media
|
||||
"categories": categories,
|
||||
"background_image_url": bg_image.get("url"),
|
||||
"social_media_links": social_links,
|
||||
# Opening hours
|
||||
"opening_hours_en": opening_hours.get("en"),
|
||||
"opening_hours_fr": opening_hours.get("fr"),
|
||||
"opening_hours_de": opening_hours.get("de"),
|
||||
# Representative
|
||||
"representative_name": data.get("representative"),
|
||||
"representative_title": data.get("representativeTitle"),
|
||||
# Raw data for reference
|
||||
"raw_data": data,
|
||||
}
|
||||
|
||||
def sync_single_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||
"""
|
||||
Sync a single vendor by slug.
|
||||
|
||||
Useful for on-demand refresh when a user looks up a vendor.
|
||||
|
||||
Args:
|
||||
slug: The vendor's URL slug.
|
||||
|
||||
Returns:
|
||||
The updated/created cache entry, or None if not found.
|
||||
"""
|
||||
client = LetzshopClient(api_key="")
|
||||
|
||||
try:
|
||||
vendor_data = client.get_vendor_by_slug(slug)
|
||||
|
||||
if not vendor_data:
|
||||
logger.warning(f"Vendor not found on Letzshop: {slug}")
|
||||
return None
|
||||
|
||||
result = self._upsert_vendor(vendor_data)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Single vendor sync: {slug} ({result})")
|
||||
|
||||
return (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.slug == slug)
|
||||
.first()
|
||||
)
|
||||
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def get_cached_vendor(self, slug: str) -> LetzshopVendorCache | None:
|
||||
"""
|
||||
Get a vendor from cache by slug.
|
||||
|
||||
Args:
|
||||
slug: The vendor's URL slug.
|
||||
|
||||
Returns:
|
||||
Cache entry or None if not found.
|
||||
"""
|
||||
return (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.slug == slug.lower())
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_cached_vendors(
|
||||
self,
|
||||
search: str | None = None,
|
||||
city: str | None = None,
|
||||
category: str | None = None,
|
||||
only_unclaimed: bool = False,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LetzshopVendorCache], int]:
|
||||
"""
|
||||
Search cached vendors with filters.
|
||||
|
||||
Args:
|
||||
search: Search term for name.
|
||||
city: Filter by city.
|
||||
category: Filter by category.
|
||||
only_unclaimed: Only return vendors not yet claimed.
|
||||
page: Page number (1-indexed).
|
||||
limit: Items per page.
|
||||
|
||||
Returns:
|
||||
Tuple of (vendors list, total count).
|
||||
"""
|
||||
query = self.db.query(LetzshopVendorCache).filter(
|
||||
LetzshopVendorCache.is_active == True # noqa: E712
|
||||
)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search.lower()}%"
|
||||
query = query.filter(
|
||||
func.lower(LetzshopVendorCache.name).like(search_term)
|
||||
)
|
||||
|
||||
if city:
|
||||
query = query.filter(
|
||||
func.lower(LetzshopVendorCache.city) == city.lower()
|
||||
)
|
||||
|
||||
if category:
|
||||
# Search in JSON array
|
||||
query = query.filter(
|
||||
LetzshopVendorCache.categories.contains([category])
|
||||
)
|
||||
|
||||
if only_unclaimed:
|
||||
query = query.filter(
|
||||
LetzshopVendorCache.claimed_by_vendor_id.is_(None)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * limit
|
||||
vendors = (
|
||||
query.order_by(LetzshopVendorCache.name)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return vendors, total
|
||||
|
||||
def get_sync_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the vendor cache.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics.
|
||||
"""
|
||||
total = self.db.query(LetzshopVendorCache).count()
|
||||
active = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.is_active == True) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
claimed = (
|
||||
self.db.query(LetzshopVendorCache)
|
||||
.filter(LetzshopVendorCache.claimed_by_vendor_id.isnot(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
# Get last sync time
|
||||
last_synced = (
|
||||
self.db.query(func.max(LetzshopVendorCache.last_synced_at)).scalar()
|
||||
)
|
||||
|
||||
# Get unique cities
|
||||
cities = (
|
||||
self.db.query(LetzshopVendorCache.city)
|
||||
.filter(LetzshopVendorCache.city.isnot(None))
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_vendors": total,
|
||||
"active_vendors": active,
|
||||
"claimed_vendors": claimed,
|
||||
"unclaimed_vendors": active - claimed,
|
||||
"unique_cities": cities,
|
||||
"last_synced_at": last_synced.isoformat() if last_synced else None,
|
||||
}
|
||||
|
||||
def mark_vendor_claimed(
|
||||
self,
|
||||
letzshop_slug: str,
|
||||
vendor_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Mark a Letzshop vendor as claimed by a platform vendor.
|
||||
|
||||
Args:
|
||||
letzshop_slug: The Letzshop vendor slug.
|
||||
vendor_id: The platform vendor ID that claimed it.
|
||||
|
||||
Returns:
|
||||
True if successful, False if vendor not found.
|
||||
"""
|
||||
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||
|
||||
if not cache_entry:
|
||||
return False
|
||||
|
||||
cache_entry.claimed_by_vendor_id = vendor_id
|
||||
cache_entry.claimed_at = datetime.now(UTC)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Vendor {letzshop_slug} claimed by vendor_id={vendor_id}")
|
||||
return True
|
||||
|
||||
def create_vendor_from_cache(
|
||||
self,
|
||||
letzshop_slug: str,
|
||||
company_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a platform vendor from a cached Letzshop vendor.
|
||||
|
||||
Args:
|
||||
letzshop_slug: The Letzshop vendor slug.
|
||||
company_id: The company ID to create the vendor under.
|
||||
|
||||
Returns:
|
||||
Dictionary with created vendor info.
|
||||
|
||||
Raises:
|
||||
ValueError: If vendor not found, already claimed, or company not found.
|
||||
"""
|
||||
import random
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.services.admin_service import admin_service
|
||||
from models.database.company import Company
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.vendor import VendorCreate
|
||||
|
||||
# Get cache entry
|
||||
cache_entry = self.get_cached_vendor(letzshop_slug)
|
||||
if not cache_entry:
|
||||
raise ValueError(f"Letzshop vendor '{letzshop_slug}' not found in cache")
|
||||
|
||||
if cache_entry.is_claimed:
|
||||
raise ValueError(
|
||||
f"Letzshop vendor '{cache_entry.name}' is already claimed "
|
||||
f"by vendor ID {cache_entry.claimed_by_vendor_id}"
|
||||
)
|
||||
|
||||
# Verify company exists
|
||||
company = self.db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise ValueError(f"Company with ID {company_id} not found")
|
||||
|
||||
# Generate vendor code from slug
|
||||
vendor_code = letzshop_slug.upper().replace("-", "_")[:20]
|
||||
|
||||
# Check if vendor code already exists
|
||||
existing = (
|
||||
self.db.query(Vendor)
|
||||
.filter(func.upper(Vendor.vendor_code) == vendor_code)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
vendor_code = f"{vendor_code[:16]}_{random.randint(100, 999)}"
|
||||
|
||||
# Generate subdomain from slug
|
||||
subdomain = letzshop_slug.lower().replace("_", "-")[:30]
|
||||
existing_subdomain = (
|
||||
self.db.query(Vendor)
|
||||
.filter(func.lower(Vendor.subdomain) == subdomain)
|
||||
.first()
|
||||
)
|
||||
if existing_subdomain:
|
||||
subdomain = f"{subdomain[:26]}-{random.randint(100, 999)}"
|
||||
|
||||
# Create vendor data from cache
|
||||
address = f"{cache_entry.street or ''} {cache_entry.street_number or ''}".strip()
|
||||
vendor_data = VendorCreate(
|
||||
name=cache_entry.name,
|
||||
vendor_code=vendor_code,
|
||||
subdomain=subdomain,
|
||||
company_id=company_id,
|
||||
email=cache_entry.email or company.email,
|
||||
phone=cache_entry.phone,
|
||||
description=cache_entry.description_en or cache_entry.description_fr or "",
|
||||
city=cache_entry.city,
|
||||
country=cache_entry.country_iso or "LU",
|
||||
website=cache_entry.website,
|
||||
address_line_1=address or None,
|
||||
postal_code=cache_entry.zipcode,
|
||||
)
|
||||
|
||||
# Create vendor
|
||||
vendor = admin_service.create_vendor(self.db, vendor_data)
|
||||
|
||||
# Mark the Letzshop vendor as claimed (commits internally) # noqa: SVC-006
|
||||
self.mark_vendor_claimed(letzshop_slug, vendor.id)
|
||||
|
||||
logger.info(
|
||||
f"Created vendor {vendor.vendor_code} from Letzshop vendor {letzshop_slug}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": vendor.id,
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"name": vendor.name,
|
||||
"subdomain": vendor.subdomain,
|
||||
"company_id": vendor.company_id,
|
||||
}
|
||||
|
||||
|
||||
# Singleton-style function for easy access
|
||||
def get_vendor_sync_service(db: Session) -> LetzshopVendorSyncService:
|
||||
"""Get a vendor sync service instance."""
|
||||
return LetzshopVendorSyncService(db)
|
||||
338
app/modules/marketplace/services/letzshop_export_service.py
Normal file
338
app/modules/marketplace/services/letzshop_export_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# app/services/letzshop_export_service.py
|
||||
"""
|
||||
Service for exporting products to Letzshop CSV format.
|
||||
|
||||
Generates Google Shopping compatible CSV files for Letzshop marketplace.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from models.database.letzshop import LetzshopSyncLog
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.product import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Letzshop CSV columns in order
|
||||
LETZSHOP_CSV_COLUMNS = [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"link",
|
||||
"image_link",
|
||||
"additional_image_link",
|
||||
"availability",
|
||||
"price",
|
||||
"sale_price",
|
||||
"brand",
|
||||
"gtin",
|
||||
"mpn",
|
||||
"google_product_category",
|
||||
"product_type",
|
||||
"condition",
|
||||
"adult",
|
||||
"multipack",
|
||||
"is_bundle",
|
||||
"age_group",
|
||||
"color",
|
||||
"gender",
|
||||
"material",
|
||||
"pattern",
|
||||
"size",
|
||||
"size_type",
|
||||
"size_system",
|
||||
"item_group_id",
|
||||
"custom_label_0",
|
||||
"custom_label_1",
|
||||
"custom_label_2",
|
||||
"custom_label_3",
|
||||
"custom_label_4",
|
||||
"identifier_exists",
|
||||
"unit_pricing_measure",
|
||||
"unit_pricing_base_measure",
|
||||
"shipping",
|
||||
"atalanda:tax_rate",
|
||||
"atalanda:quantity",
|
||||
"atalanda:boost_sort",
|
||||
"atalanda:delivery_method",
|
||||
]
|
||||
|
||||
|
||||
class LetzshopExportService:
|
||||
"""Service for exporting products to Letzshop CSV format."""
|
||||
|
||||
def __init__(self, default_tax_rate: float = 17.0):
|
||||
"""
|
||||
Initialize the export service.
|
||||
|
||||
Args:
|
||||
default_tax_rate: Default VAT rate for Luxembourg (17%)
|
||||
"""
|
||||
self.default_tax_rate = default_tax_rate
|
||||
|
||||
def export_vendor_products(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
language: str = "en",
|
||||
include_inactive: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Export all products for a vendor in Letzshop CSV format.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID to export products for
|
||||
language: Language for title/description (en, fr, de)
|
||||
include_inactive: Whether to include inactive products
|
||||
|
||||
Returns:
|
||||
CSV string content
|
||||
"""
|
||||
# Query products for this vendor with their marketplace product data
|
||||
query = (
|
||||
db.query(Product)
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.options(
|
||||
joinedload(Product.marketplace_product).joinedload(
|
||||
MarketplaceProduct.translations
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(Product.is_active == True)
|
||||
|
||||
products = query.all()
|
||||
|
||||
logger.info(
|
||||
f"Exporting {len(products)} products for vendor {vendor_id} in {language}"
|
||||
)
|
||||
|
||||
return self._generate_csv(products, language)
|
||||
|
||||
def export_marketplace_products(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: str = "Letzshop",
|
||||
language: str = "en",
|
||||
limit: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Export marketplace products directly (admin use).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
marketplace: Filter by marketplace source
|
||||
language: Language for title/description
|
||||
limit: Optional limit on number of products
|
||||
|
||||
Returns:
|
||||
CSV string content
|
||||
"""
|
||||
query = (
|
||||
db.query(MarketplaceProduct)
|
||||
.filter(MarketplaceProduct.is_active == True)
|
||||
.options(joinedload(MarketplaceProduct.translations))
|
||||
)
|
||||
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceProduct.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
products = query.all()
|
||||
|
||||
logger.info(
|
||||
f"Exporting {len(products)} marketplace products for {marketplace} in {language}"
|
||||
)
|
||||
|
||||
return self._generate_csv_from_marketplace_products(products, language)
|
||||
|
||||
def _generate_csv(self, products: list[Product], language: str) -> str:
|
||||
"""Generate CSV from vendor Product objects."""
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(
|
||||
output,
|
||||
fieldnames=LETZSHOP_CSV_COLUMNS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
)
|
||||
writer.writeheader()
|
||||
|
||||
for product in products:
|
||||
if product.marketplace_product:
|
||||
row = self._product_to_row(product, language)
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def _generate_csv_from_marketplace_products(
|
||||
self, products: list[MarketplaceProduct], language: str
|
||||
) -> str:
|
||||
"""Generate CSV from MarketplaceProduct objects directly."""
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(
|
||||
output,
|
||||
fieldnames=LETZSHOP_CSV_COLUMNS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
)
|
||||
writer.writeheader()
|
||||
|
||||
for mp in products:
|
||||
row = self._marketplace_product_to_row(mp, language)
|
||||
writer.writerow(row)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
def _product_to_row(self, product: Product, language: str) -> dict:
|
||||
"""Convert a Product (with MarketplaceProduct) to a CSV row."""
|
||||
mp = product.marketplace_product
|
||||
return self._marketplace_product_to_row(
|
||||
mp, language, vendor_sku=product.vendor_sku
|
||||
)
|
||||
|
||||
def _marketplace_product_to_row(
|
||||
self,
|
||||
mp: MarketplaceProduct,
|
||||
language: str,
|
||||
vendor_sku: str | None = None,
|
||||
) -> dict:
|
||||
"""Convert a MarketplaceProduct to a CSV row dict."""
|
||||
# Get localized title and description
|
||||
title = mp.get_title(language) or ""
|
||||
description = mp.get_description(language) or ""
|
||||
|
||||
# Format price with currency
|
||||
price = ""
|
||||
if mp.price_numeric:
|
||||
price = f"{mp.price_numeric:.2f} {mp.currency or 'EUR'}"
|
||||
elif mp.price:
|
||||
price = mp.price
|
||||
|
||||
# Format sale price
|
||||
sale_price = ""
|
||||
if mp.sale_price_numeric:
|
||||
sale_price = f"{mp.sale_price_numeric:.2f} {mp.currency or 'EUR'}"
|
||||
elif mp.sale_price:
|
||||
sale_price = mp.sale_price
|
||||
|
||||
# Additional images - join with comma if multiple
|
||||
additional_images = ""
|
||||
if mp.additional_images:
|
||||
additional_images = ",".join(mp.additional_images)
|
||||
elif mp.additional_image_link:
|
||||
additional_images = mp.additional_image_link
|
||||
|
||||
# Determine identifier_exists
|
||||
identifier_exists = mp.identifier_exists
|
||||
if not identifier_exists:
|
||||
identifier_exists = "yes" if (mp.gtin or mp.mpn) else "no"
|
||||
|
||||
return {
|
||||
"id": vendor_sku or mp.marketplace_product_id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"link": mp.link or mp.source_url or "",
|
||||
"image_link": mp.image_link or "",
|
||||
"additional_image_link": additional_images,
|
||||
"availability": mp.availability or "in stock",
|
||||
"price": price,
|
||||
"sale_price": sale_price,
|
||||
"brand": mp.brand or "",
|
||||
"gtin": mp.gtin or "",
|
||||
"mpn": mp.mpn or "",
|
||||
"google_product_category": mp.google_product_category or "",
|
||||
"product_type": mp.product_type_raw or "",
|
||||
"condition": mp.condition or "new",
|
||||
"adult": mp.adult or "no",
|
||||
"multipack": str(mp.multipack) if mp.multipack else "",
|
||||
"is_bundle": mp.is_bundle or "no",
|
||||
"age_group": mp.age_group or "",
|
||||
"color": mp.color or "",
|
||||
"gender": mp.gender or "",
|
||||
"material": mp.material or "",
|
||||
"pattern": mp.pattern or "",
|
||||
"size": mp.size or "",
|
||||
"size_type": mp.size_type or "",
|
||||
"size_system": mp.size_system or "",
|
||||
"item_group_id": mp.item_group_id or "",
|
||||
"custom_label_0": mp.custom_label_0 or "",
|
||||
"custom_label_1": mp.custom_label_1 or "",
|
||||
"custom_label_2": mp.custom_label_2 or "",
|
||||
"custom_label_3": mp.custom_label_3 or "",
|
||||
"custom_label_4": mp.custom_label_4 or "",
|
||||
"identifier_exists": identifier_exists,
|
||||
"unit_pricing_measure": mp.unit_pricing_measure or "",
|
||||
"unit_pricing_base_measure": mp.unit_pricing_base_measure or "",
|
||||
"shipping": mp.shipping or "",
|
||||
"atalanda:tax_rate": str(self.default_tax_rate),
|
||||
"atalanda:quantity": "", # Would need inventory data
|
||||
"atalanda:boost_sort": "",
|
||||
"atalanda:delivery_method": "",
|
||||
}
|
||||
|
||||
def log_export(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
started_at: datetime,
|
||||
completed_at: datetime,
|
||||
files_processed: int,
|
||||
files_succeeded: int,
|
||||
files_failed: int,
|
||||
products_exported: int,
|
||||
triggered_by: str,
|
||||
error_details: dict | None = None,
|
||||
) -> LetzshopSyncLog:
|
||||
"""
|
||||
Log an export operation to the sync log.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
started_at: When the export started
|
||||
completed_at: When the export completed
|
||||
files_processed: Number of language files to export (e.g., 3)
|
||||
files_succeeded: Number of files successfully exported
|
||||
files_failed: Number of files that failed
|
||||
products_exported: Total products in the export
|
||||
triggered_by: Who triggered the export (e.g., "admin:123")
|
||||
error_details: Optional error details if any failures
|
||||
|
||||
Returns:
|
||||
Created LetzshopSyncLog entry
|
||||
"""
|
||||
sync_log = LetzshopSyncLog(
|
||||
vendor_id=vendor_id,
|
||||
operation_type="product_export",
|
||||
direction="outbound",
|
||||
status="completed" if files_failed == 0 else "partial",
|
||||
records_processed=files_processed,
|
||||
records_succeeded=files_succeeded,
|
||||
records_failed=files_failed,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
duration_seconds=int((completed_at - started_at).total_seconds()),
|
||||
triggered_by=triggered_by,
|
||||
error_details={
|
||||
"products_exported": products_exported,
|
||||
**(error_details or {}),
|
||||
} if products_exported or error_details else None,
|
||||
)
|
||||
db.add(sync_log)
|
||||
db.flush()
|
||||
return sync_log
|
||||
|
||||
|
||||
# Singleton instance
|
||||
letzshop_export_service = LetzshopExportService()
|
||||
@@ -0,0 +1,334 @@
|
||||
# app/services/marketplace_import_job_service.py
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ImportJobNotFoundException,
|
||||
ImportJobNotOwnedException,
|
||||
ValidationException,
|
||||
)
|
||||
from models.database.marketplace_import_job import (
|
||||
MarketplaceImportError,
|
||||
MarketplaceImportJob,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.marketplace_import_job import (
|
||||
AdminMarketplaceImportJobResponse,
|
||||
MarketplaceImportJobRequest,
|
||||
MarketplaceImportJobResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketplaceImportJobService:
|
||||
"""Service class for Marketplace operations."""
|
||||
|
||||
def create_import_job(
|
||||
self,
|
||||
db: Session,
|
||||
request: MarketplaceImportJobRequest,
|
||||
vendor: Vendor, # CHANGED: Vendor object from middleware
|
||||
user: User,
|
||||
) -> MarketplaceImportJob:
|
||||
"""
|
||||
Create a new marketplace import job.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
request: Import request data
|
||||
vendor: Vendor object (from middleware)
|
||||
user: User creating the job
|
||||
|
||||
Returns:
|
||||
Created MarketplaceImportJob object
|
||||
"""
|
||||
try:
|
||||
# Create marketplace import job record
|
||||
import_job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
source_url=request.source_url,
|
||||
marketplace=request.marketplace,
|
||||
language=request.language,
|
||||
vendor_id=vendor.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.flush()
|
||||
db.refresh(import_job)
|
||||
|
||||
logger.info(
|
||||
f"Created marketplace import job {import_job.id}: "
|
||||
f"{request.marketplace} -> {vendor.name} (code: {vendor.vendor_code}) "
|
||||
f"by user {user.username}"
|
||||
)
|
||||
|
||||
return import_job
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating import job: {str(e)}")
|
||||
raise ValidationException("Failed to create import job")
|
||||
|
||||
def get_import_job_by_id(
|
||||
self, db: Session, job_id: int, user: User
|
||||
) -> MarketplaceImportJob:
|
||||
"""Get a marketplace import job by ID with access control."""
|
||||
try:
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not job:
|
||||
raise ImportJobNotFoundException(job_id)
|
||||
|
||||
# Users can only see their own jobs, admins can see all
|
||||
if user.role != "admin" and job.user_id != user.id:
|
||||
raise ImportJobNotOwnedException(job_id, user.id)
|
||||
|
||||
return job
|
||||
|
||||
except (ImportJobNotFoundException, ImportJobNotOwnedException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting import job {job_id}: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import job")
|
||||
|
||||
def get_import_job_for_vendor(
|
||||
self, db: Session, job_id: int, vendor_id: int
|
||||
) -> MarketplaceImportJob:
|
||||
"""
|
||||
Get a marketplace import job by ID with vendor access control.
|
||||
|
||||
Validates that the job belongs to the specified vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
job_id: Import job ID
|
||||
vendor_id: Vendor ID from token (to verify ownership)
|
||||
|
||||
Raises:
|
||||
ImportJobNotFoundException: If job not found
|
||||
UnauthorizedVendorAccessException: If job doesn't belong to vendor
|
||||
"""
|
||||
from app.exceptions import UnauthorizedVendorAccessException
|
||||
|
||||
try:
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not job:
|
||||
raise ImportJobNotFoundException(job_id)
|
||||
|
||||
# Verify job belongs to vendor (service layer validation)
|
||||
if job.vendor_id != vendor_id:
|
||||
raise UnauthorizedVendorAccessException(
|
||||
vendor_code=str(vendor_id),
|
||||
user_id=0, # Not user-specific, but vendor mismatch
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
except (ImportJobNotFoundException, UnauthorizedVendorAccessException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error getting import job {job_id} for vendor {vendor_id}: {str(e)}"
|
||||
)
|
||||
raise ValidationException("Failed to retrieve import job")
|
||||
|
||||
def get_import_jobs(
|
||||
self,
|
||||
db: Session,
|
||||
vendor: Vendor, # ADDED: Vendor filter
|
||||
user: User,
|
||||
marketplace: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[MarketplaceImportJob]:
|
||||
"""Get marketplace import jobs for a specific vendor."""
|
||||
try:
|
||||
query = db.query(MarketplaceImportJob).filter(
|
||||
MarketplaceImportJob.vendor_id == vendor.id
|
||||
)
|
||||
|
||||
# Users can only see their own jobs, admins can see all vendor jobs
|
||||
if user.role != "admin":
|
||||
query = query.filter(MarketplaceImportJob.user_id == user.id)
|
||||
|
||||
# Apply marketplace filter
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
|
||||
# Order by creation date (newest first) and apply pagination
|
||||
jobs = (
|
||||
query.order_by(
|
||||
MarketplaceImportJob.created_at.desc(),
|
||||
MarketplaceImportJob.id.desc(), # Tiebreaker for same timestamp
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return jobs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting import jobs: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import jobs")
|
||||
|
||||
def convert_to_response_model(
|
||||
self, job: MarketplaceImportJob
|
||||
) -> MarketplaceImportJobResponse:
|
||||
"""Convert database model to API response model."""
|
||||
return MarketplaceImportJobResponse(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
marketplace=job.marketplace,
|
||||
language=job.language,
|
||||
vendor_id=job.vendor_id,
|
||||
vendor_code=job.vendor.vendor_code if job.vendor else None,
|
||||
vendor_name=job.vendor.name if job.vendor else None,
|
||||
source_url=job.source_url,
|
||||
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,
|
||||
)
|
||||
|
||||
def convert_to_admin_response_model(
|
||||
self, job: MarketplaceImportJob
|
||||
) -> AdminMarketplaceImportJobResponse:
|
||||
"""Convert database model to admin API response model with extra fields."""
|
||||
return AdminMarketplaceImportJobResponse(
|
||||
id=job.id,
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
marketplace=job.marketplace,
|
||||
language=job.language,
|
||||
vendor_id=job.vendor_id,
|
||||
vendor_code=job.vendor.vendor_code if job.vendor else None,
|
||||
vendor_name=job.vendor.name if job.vendor else None,
|
||||
source_url=job.source_url,
|
||||
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,
|
||||
error_details=[],
|
||||
created_at=job.created_at,
|
||||
started_at=job.started_at,
|
||||
completed_at=job.completed_at,
|
||||
created_by_name=job.user.username if job.user else None,
|
||||
)
|
||||
|
||||
def get_all_import_jobs_paginated(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[MarketplaceImportJob], int]:
|
||||
"""Get all marketplace import jobs with pagination (for admin)."""
|
||||
try:
|
||||
query = db.query(MarketplaceImportJob)
|
||||
|
||||
if marketplace:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
if status:
|
||||
query = query.filter(MarketplaceImportJob.status == status)
|
||||
|
||||
total = query.count()
|
||||
skip = (page - 1) * limit
|
||||
jobs = (
|
||||
query.order_by(
|
||||
MarketplaceImportJob.created_at.desc(),
|
||||
MarketplaceImportJob.id.desc(), # Tiebreaker for same timestamp
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return jobs, total
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all import jobs: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import jobs")
|
||||
|
||||
def get_import_job_by_id_admin(
|
||||
self, db: Session, job_id: int
|
||||
) -> MarketplaceImportJob:
|
||||
"""Get a marketplace import job by ID (admin - no access control)."""
|
||||
job = (
|
||||
db.query(MarketplaceImportJob)
|
||||
.filter(MarketplaceImportJob.id == job_id)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise ImportJobNotFoundException(job_id)
|
||||
return job
|
||||
|
||||
def get_import_job_errors(
|
||||
self,
|
||||
db: Session,
|
||||
job_id: int,
|
||||
error_type: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[MarketplaceImportError], int]:
|
||||
"""
|
||||
Get import errors for a specific job with pagination.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
job_id: Import job ID
|
||||
error_type: Optional filter by error type
|
||||
page: Page number (1-indexed)
|
||||
limit: Number of items per page
|
||||
|
||||
Returns:
|
||||
Tuple of (list of errors, total count)
|
||||
"""
|
||||
try:
|
||||
query = db.query(MarketplaceImportError).filter(
|
||||
MarketplaceImportError.import_job_id == job_id
|
||||
)
|
||||
|
||||
if error_type:
|
||||
query = query.filter(MarketplaceImportError.error_type == error_type)
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
errors = (
|
||||
query.order_by(MarketplaceImportError.row_number)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return errors, total
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting import job errors for job {job_id}: {str(e)}")
|
||||
raise ValidationException("Failed to retrieve import errors")
|
||||
|
||||
|
||||
marketplace_import_job_service = MarketplaceImportJobService()
|
||||
1075
app/modules/marketplace/services/marketplace_product_service.py
Normal file
1075
app/modules/marketplace/services/marketplace_product_service.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user