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:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

File diff suppressed because it is too large Load Diff