refactor: migrate models to canonical module locations

- Move Product/ProductTranslation to app/modules/catalog/models/
- Move VendorOnboarding to app/modules/marketplace/models/
- Delete legacy re-export files for marketplace models:
  - letzshop.py, marketplace.py, marketplace_product.py
  - marketplace_product_translation.py, marketplace_import_job.py
- Delete legacy product.py, product_translation.py, onboarding.py
- Update all imports across services, tasks, tests to use module locations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 14:45:32 +01:00
parent a123341aa8
commit 0c63f387aa
72 changed files with 176 additions and 276 deletions

View File

@@ -57,13 +57,18 @@ from app.modules.orders.models import (
VATRegime,
VendorInvoiceSettings,
)
from .letzshop import (
from app.modules.marketplace.models import (
LetzshopFulfillmentQueue,
LetzshopHistoricalImportJob,
LetzshopSyncLog,
VendorLetzshopCredentials,
MarketplaceImportError,
MarketplaceImportJob,
DigitalDeliveryMethod,
MarketplaceProduct,
ProductType,
MarketplaceProductTranslation,
)
from .marketplace_import_job import MarketplaceImportError, MarketplaceImportJob
from app.modules.messaging.models import (
Conversation,
ConversationParticipant,
@@ -72,18 +77,11 @@ from app.modules.messaging.models import (
MessageAttachment,
ParticipantType,
)
from .marketplace_product import (
DigitalDeliveryMethod,
MarketplaceProduct,
ProductType,
)
from .media import MediaFile, ProductMedia
from .marketplace_product_translation import MarketplaceProductTranslation
from .onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding
from app.modules.marketplace.models import OnboardingStatus, OnboardingStep, VendorOnboarding
from app.modules.orders.models import Order, OrderItem
from app.modules.orders.models import OrderItemException
from .product import Product
from .product_translation import ProductTranslation
from app.modules.catalog.models import Product, ProductTranslation
from .subscription import (
AddOnCategory,
AddOnProduct,

View File

@@ -1,34 +0,0 @@
# models/database/letzshop.py
"""
Legacy location for Letzshop models.
MIGRATED: Models have been moved to app.modules.marketplace.models.letzshop.
New location:
from app.modules.marketplace.models import (
VendorLetzshopCredentials,
LetzshopFulfillmentQueue,
LetzshopVendorCache,
LetzshopSyncLog,
LetzshopHistoricalImportJob,
)
This file re-exports from the new location for backward compatibility.
"""
# Re-export from the new canonical location
from app.modules.marketplace.models.letzshop import (
VendorLetzshopCredentials,
LetzshopFulfillmentQueue,
LetzshopVendorCache,
LetzshopSyncLog,
LetzshopHistoricalImportJob,
)
__all__ = [
"VendorLetzshopCredentials",
"LetzshopFulfillmentQueue",
"LetzshopVendorCache",
"LetzshopSyncLog",
"LetzshopHistoricalImportJob",
]

View File

@@ -1 +0,0 @@
# MarketplaceImportJob model

View File

@@ -1,22 +0,0 @@
# models/database/marketplace_import_job.py
"""
Legacy location for marketplace import job models.
MIGRATED: Models have been moved to app.modules.marketplace.models.marketplace_import_job.
New location:
from app.modules.marketplace.models import (
MarketplaceImportJob,
MarketplaceImportError,
)
This file re-exports from the new location for backward compatibility.
"""
# Re-export from the new canonical location
from app.modules.marketplace.models.marketplace_import_job import (
MarketplaceImportJob,
MarketplaceImportError,
)
__all__ = ["MarketplaceImportJob", "MarketplaceImportError"]

View File

@@ -1,28 +0,0 @@
# models/database/marketplace_product.py
"""
Legacy location for marketplace product model.
MIGRATED: All models have been moved to app.modules.marketplace.models.marketplace_product.
New location:
from app.modules.marketplace.models import (
MarketplaceProduct,
ProductType,
DigitalDeliveryMethod,
)
This file re-exports from the new location for backward compatibility.
"""
# Re-export everything from the new canonical location
from app.modules.marketplace.models.marketplace_product import (
MarketplaceProduct,
ProductType,
DigitalDeliveryMethod,
)
__all__ = [
"MarketplaceProduct",
"ProductType",
"DigitalDeliveryMethod",
]

View File

@@ -1,18 +0,0 @@
# models/database/marketplace_product_translation.py
"""
Legacy location for marketplace product translation model.
MIGRATED: Model has been moved to app.modules.marketplace.models.marketplace_product_translation.
New location:
from app.modules.marketplace.models import MarketplaceProductTranslation
This file re-exports from the new location for backward compatibility.
"""
# Re-export from the new canonical location
from app.modules.marketplace.models.marketplace_product_translation import (
MarketplaceProductTranslation,
)
__all__ = ["MarketplaceProductTranslation"]

View File

@@ -1,222 +0,0 @@
# models/database/onboarding.py
"""
Vendor onboarding progress tracking.
Tracks completion status of mandatory onboarding steps for new vendors.
Onboarding must be completed before accessing the vendor dashboard.
"""
import enum
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class OnboardingStep(str, enum.Enum):
"""Onboarding step identifiers."""
COMPANY_PROFILE = "company_profile"
LETZSHOP_API = "letzshop_api"
PRODUCT_IMPORT = "product_import"
ORDER_SYNC = "order_sync"
class OnboardingStatus(str, enum.Enum):
"""Overall onboarding status."""
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
SKIPPED = "skipped" # For admin override capability
# Step order for validation
STEP_ORDER = [
OnboardingStep.COMPANY_PROFILE,
OnboardingStep.LETZSHOP_API,
OnboardingStep.PRODUCT_IMPORT,
OnboardingStep.ORDER_SYNC,
]
class VendorOnboarding(Base, TimestampMixin):
"""
Per-vendor onboarding progress tracking.
Created automatically when vendor is created during signup.
Blocks dashboard access until status = 'completed' or skipped_by_admin = True.
"""
__tablename__ = "vendor_onboarding"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer,
ForeignKey("vendors.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
# Overall status
status = Column(
String(20),
default=OnboardingStatus.NOT_STARTED.value,
nullable=False,
index=True,
)
current_step = Column(
String(30),
default=OnboardingStep.COMPANY_PROFILE.value,
nullable=False,
)
# Step 1: Company Profile
step_company_profile_completed = Column(Boolean, default=False, nullable=False)
step_company_profile_completed_at = Column(DateTime(timezone=True), nullable=True)
step_company_profile_data = Column(JSON, nullable=True) # Store what was entered
# Step 2: Letzshop API Configuration
step_letzshop_api_completed = Column(Boolean, default=False, nullable=False)
step_letzshop_api_completed_at = Column(DateTime(timezone=True), nullable=True)
step_letzshop_api_connection_verified = Column(Boolean, default=False, nullable=False)
# Step 3: Product & Order Import (CSV feed URL + historical import)
step_product_import_completed = Column(Boolean, default=False, nullable=False)
step_product_import_completed_at = Column(DateTime(timezone=True), nullable=True)
step_product_import_csv_url_set = Column(Boolean, default=False, nullable=False)
# Step 4: Order Sync
step_order_sync_completed = Column(Boolean, default=False, nullable=False)
step_order_sync_completed_at = Column(DateTime(timezone=True), nullable=True)
step_order_sync_job_id = Column(Integer, nullable=True) # FK to LetzshopHistoricalImportJob
# Completion tracking
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Admin override (for support cases)
skipped_by_admin = Column(Boolean, default=False, nullable=False)
skipped_at = Column(DateTime(timezone=True), nullable=True)
skipped_reason = Column(Text, nullable=True)
skipped_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="onboarding")
__table_args__ = (
Index("idx_onboarding_vendor_status", "vendor_id", "status"),
{"sqlite_autoincrement": True},
)
def __repr__(self):
return f"<VendorOnboarding(vendor_id={self.vendor_id}, status='{self.status}', step='{self.current_step}')>"
@property
def is_completed(self) -> bool:
"""Check if onboarding is fully completed or skipped."""
return (
self.status == OnboardingStatus.COMPLETED.value or self.skipped_by_admin
)
@property
def completion_percentage(self) -> int:
"""Calculate completion percentage (0-100)."""
completed_steps = sum(
[
self.step_company_profile_completed,
self.step_letzshop_api_completed,
self.step_product_import_completed,
self.step_order_sync_completed,
]
)
return int((completed_steps / 4) * 100)
@property
def completed_steps_count(self) -> int:
"""Get number of completed steps."""
return sum(
[
self.step_company_profile_completed,
self.step_letzshop_api_completed,
self.step_product_import_completed,
self.step_order_sync_completed,
]
)
def is_step_completed(self, step: str) -> bool:
"""Check if a specific step is completed."""
step_mapping = {
OnboardingStep.COMPANY_PROFILE.value: self.step_company_profile_completed,
OnboardingStep.LETZSHOP_API.value: self.step_letzshop_api_completed,
OnboardingStep.PRODUCT_IMPORT.value: self.step_product_import_completed,
OnboardingStep.ORDER_SYNC.value: self.step_order_sync_completed,
}
return step_mapping.get(step, False)
def can_proceed_to_step(self, step: str) -> bool:
"""Check if user can proceed to a specific step (no skipping)."""
target_index = None
for i, s in enumerate(STEP_ORDER):
if s.value == step:
target_index = i
break
if target_index is None:
return False
# Check all previous steps are completed
for i in range(target_index):
if not self.is_step_completed(STEP_ORDER[i].value):
return False
return True
def get_next_step(self) -> str | None:
"""Get the next incomplete step."""
for step in STEP_ORDER:
if not self.is_step_completed(step.value):
return step.value
return None
def mark_step_complete(self, step: str, timestamp: datetime | None = None) -> None:
"""Mark a step as complete and update current step."""
if timestamp is None:
timestamp = datetime.utcnow()
if step == OnboardingStep.COMPANY_PROFILE.value:
self.step_company_profile_completed = True
self.step_company_profile_completed_at = timestamp
elif step == OnboardingStep.LETZSHOP_API.value:
self.step_letzshop_api_completed = True
self.step_letzshop_api_completed_at = timestamp
elif step == OnboardingStep.PRODUCT_IMPORT.value:
self.step_product_import_completed = True
self.step_product_import_completed_at = timestamp
elif step == OnboardingStep.ORDER_SYNC.value:
self.step_order_sync_completed = True
self.step_order_sync_completed_at = timestamp
# Update current step to next incomplete step
next_step = self.get_next_step()
if next_step:
self.current_step = next_step
else:
# All steps complete
self.status = OnboardingStatus.COMPLETED.value
self.completed_at = timestamp

View File

@@ -1,346 +0,0 @@
"""Vendor Product model - independent copy pattern.
This model represents a vendor's product. Products can be:
1. Created from a marketplace import (has marketplace_product_id)
2. Created directly by the vendor (no marketplace_product_id)
When created from marketplace, the marketplace_product_id FK provides
"view original source" comparison feature.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
"""
from sqlalchemy import (
Boolean,
Column,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from app.utils.money import cents_to_euros, euros_to_cents
from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
"""Vendor-specific product.
Products can be created from marketplace imports or directly by vendors.
When from marketplace, marketplace_product_id provides source comparison.
Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(
Integer, ForeignKey("marketplace_products.id"), nullable=True
)
# === VENDOR REFERENCE ===
vendor_sku = Column(String, index=True) # Vendor's internal SKU
# === PRODUCT IDENTIFIERS ===
# GTIN (Global Trade Item Number) - barcode for EAN matching with orders
# Populated from MarketplaceProduct.gtin during product import
gtin = Column(String(50), index=True) # EAN/UPC barcode number
gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10
# === PRODUCT FIELDS (copied from marketplace at creation) ===
# Pricing - stored as integer cents (€19.99 = 1999)
price_cents = Column(Integer) # Price in cents
sale_price_cents = Column(Integer) # Sale price in cents
currency = Column(String(3), default="EUR")
# Product Info
brand = Column(String)
condition = Column(String)
availability = Column(String)
# Media
primary_image_url = Column(String)
additional_images = Column(JSON)
# Digital Product Fields
download_url = Column(String)
license_type = Column(String(50))
# === TAX / VAT ===
# Luxembourg VAT rates: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
# Prices are stored as gross (VAT-inclusive). Tax rate is used for profit calculation.
tax_rate_percent = Column(Integer, default=17, nullable=False)
# === SUPPLIER TRACKING & COST ===
supplier = Column(String(50)) # 'codeswholesale', 'internal', etc.
supplier_product_id = Column(String) # Supplier's product reference
cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation
margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
# === PRODUCT TYPE ===
is_digital = Column(Boolean, default=False, index=True)
product_type = Column(String(20), default="physical") # physical, digital, service, subscription
# === VENDOR-SPECIFIC ===
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
# Inventory Settings
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Digital Fulfillment
fulfillment_email_template = Column(String) # Template name for digital delivery
# === RELATIONSHIPS ===
vendor = relationship("Vendor", back_populates="products")
marketplace_product = relationship(
"MarketplaceProduct", back_populates="vendor_products"
)
translations = relationship(
"ProductTranslation",
back_populates="product",
cascade="all, delete-orphan",
)
inventory_entries = relationship(
"Inventory", back_populates="product", cascade="all, delete-orphan"
)
# === CONSTRAINTS & INDEXES ===
__table_args__ = (
UniqueConstraint(
"vendor_id", "marketplace_product_id", name="uq_vendor_marketplace_product"
),
Index("idx_product_vendor_active", "vendor_id", "is_active"),
Index("idx_product_vendor_featured", "vendor_id", "is_featured"),
Index("idx_product_vendor_sku", "vendor_id", "vendor_sku"),
Index("idx_product_supplier", "supplier", "supplier_product_id"),
)
def __repr__(self):
return (
f"<Product(id={self.id}, vendor_id={self.vendor_id}, "
f"vendor_sku='{self.vendor_sku}')>"
)
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def price(self) -> float | None:
"""Get price in euros (for API/display)."""
if self.price_cents is not None:
return cents_to_euros(self.price_cents)
return None
@price.setter
def price(self, value: float | None):
"""Set price from euros."""
self.price_cents = euros_to_cents(value) if value is not None else None
@property
def sale_price(self) -> float | None:
"""Get sale price in euros (for API/display)."""
if self.sale_price_cents is not None:
return cents_to_euros(self.sale_price_cents)
return None
@sale_price.setter
def sale_price(self, value: float | None):
"""Set sale price from euros."""
self.sale_price_cents = euros_to_cents(value) if value is not None else None
@property
def cost(self) -> float | None:
"""Get cost in euros (what vendor pays to acquire)."""
if self.cost_cents is not None:
return cents_to_euros(self.cost_cents)
return None
@cost.setter
def cost(self, value: float | None):
"""Set cost from euros."""
self.cost_cents = euros_to_cents(value) if value is not None else None
@property
def margin_percent(self) -> float | None:
"""Get margin percent (e.g., 25.5)."""
if self.margin_percent_x100 is not None:
return self.margin_percent_x100 / 100.0
return None
@margin_percent.setter
def margin_percent(self, value: float | None):
"""Set margin percent."""
self.margin_percent_x100 = int(value * 100) if value is not None else None
# === TAX / PROFIT CALCULATION PROPERTIES ===
@property
def net_price_cents(self) -> int | None:
"""Calculate net price (excluding VAT) from gross price.
Formula: Net = Gross / (1 + rate/100)
Example: €119 gross at 17% VAT = €119 / 1.17 = €101.71 net
"""
if self.price_cents is None:
return None
# Use integer math to avoid floating point issues
# Net = Gross * 100 / (100 + rate)
return int(self.price_cents * 100 / (100 + self.tax_rate_percent))
@property
def net_price(self) -> float | None:
"""Get net price in euros."""
cents = self.net_price_cents
return cents_to_euros(cents) if cents is not None else None
@property
def vat_amount_cents(self) -> int | None:
"""Calculate VAT amount in cents.
Formula: VAT = Gross - Net
"""
if self.price_cents is None:
return None
net = self.net_price_cents
if net is None:
return None
return self.price_cents - net
@property
def vat_amount(self) -> float | None:
"""Get VAT amount in euros."""
cents = self.vat_amount_cents
return cents_to_euros(cents) if cents is not None else None
@property
def profit_cents(self) -> int | None:
"""Calculate profit in cents.
Formula: Profit = Net Revenue - Cost
Returns None if cost is not set.
"""
net = self.net_price_cents
if net is None or self.cost_cents is None:
return None
return net - self.cost_cents
@property
def profit(self) -> float | None:
"""Get profit in euros."""
cents = self.profit_cents
return cents_to_euros(cents) if cents is not None else None
@property
def profit_margin_percent(self) -> float | None:
"""Calculate profit margin as percentage of net revenue.
Formula: Margin% = (Profit / Net) * 100
Example: €41.71 profit on €101.71 net = 41.0% margin
"""
net = self.net_price_cents
profit = self.profit_cents
if net is None or profit is None or net == 0:
return None
return round((profit / net) * 100, 2)
# === INVENTORY PROPERTIES ===
# Constant for unlimited inventory (digital products)
UNLIMITED_INVENTORY = 999999
@property
def has_unlimited_inventory(self) -> bool:
"""Check if product has unlimited inventory.
Digital products have unlimited inventory by default.
They don't require physical stock tracking.
"""
return self.is_digital
@property
def total_inventory(self) -> int:
"""Calculate total inventory across all locations.
Digital products return unlimited inventory.
"""
if self.has_unlimited_inventory:
return self.UNLIMITED_INVENTORY
return sum(inv.quantity for inv in self.inventory_entries)
@property
def available_inventory(self) -> int:
"""Calculate available inventory (total - reserved).
Digital products return unlimited inventory since they
don't have physical stock constraints.
"""
if self.has_unlimited_inventory:
return self.UNLIMITED_INVENTORY
return sum(inv.available_quantity for inv in self.inventory_entries)
# === SOURCE COMPARISON METHOD ===
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
Returns a dict with current field values and original source values
from the marketplace product. Used for "view original source" feature.
Only populated when product was created from a marketplace source.
"""
mp = self.marketplace_product
return {
# Price
"price": self.price,
"price_cents": self.price_cents,
"price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None,
# Sale Price
"sale_price": self.sale_price,
"sale_price_cents": self.sale_price_cents,
"sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None,
# Currency
"currency": self.currency,
"currency_source": mp.currency if mp else None,
# Brand
"brand": self.brand,
"brand_source": mp.brand if mp else None,
# Condition
"condition": self.condition,
"condition_source": mp.condition if mp else None,
# Availability
"availability": self.availability,
"availability_source": mp.availability if mp else None,
# Images
"primary_image_url": self.primary_image_url,
"primary_image_url_source": mp.image_link if mp else None,
# Product type (independent fields, no source comparison)
"is_digital": self.is_digital,
"product_type": self.product_type,
}
# === TRANSLATION HELPERS ===
def get_translation(self, language: str) -> "ProductTranslation | None":
"""Get translation for a specific language."""
for t in self.translations:
if t.language == language:
return t
return None
def get_title(self, language: str = "en") -> str | None:
"""Get title for a specific language."""
translation = self.get_translation(language)
return translation.title if translation else None
def get_description(self, language: str = "en") -> str | None:
"""Get description for a specific language."""
translation = self.get_translation(language)
return translation.description if translation else None

View File

@@ -1,114 +0,0 @@
"""Product Translation model for vendor-specific localized content.
This model stores vendor-specific translations. Translations are independent
entities with all fields populated at creation time from the source
marketplace product translation.
The marketplace product translation can be accessed via the product's
marketplace_product relationship for "view original source" comparison.
"""
from sqlalchemy import (
Column,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ProductTranslation(Base, TimestampMixin):
"""Vendor-specific localized content - independent copy.
Each vendor has their own translations with all fields populated
at creation time. The source marketplace translation can be accessed
for comparison via the product's marketplace_product relationship.
"""
__tablename__ = "product_translations"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(
Integer,
ForeignKey("products.id", ondelete="CASCADE"),
nullable=False,
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === LOCALIZED FIELDS (copied from marketplace at creation) ===
title = Column(String)
description = Column(Text)
short_description = Column(String(500))
# SEO Fields
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
# === RELATIONSHIPS ===
product = relationship("Product", back_populates="translations")
__table_args__ = (
UniqueConstraint("product_id", "language", name="uq_product_translation"),
Index("idx_pt_product_id", "product_id"),
Index("idx_pt_product_language", "product_id", "language"),
)
def __repr__(self):
return (
f"<ProductTranslation(id={self.id}, "
f"product_id={self.product_id}, "
f"language='{self.language}', "
f"title='{self.title[:30] if self.title else None}...')>"
)
# === SOURCE COMPARISON METHOD ===
def _find_marketplace_translation(self):
"""Get the corresponding marketplace translation for comparison."""
product = self.product
if product and product.marketplace_product:
mp = product.marketplace_product
for t in mp.translations:
if t.language == self.language:
return t
return None
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
Returns a dict with current field values and original source values
from the marketplace product translation. Used for "view original source" feature.
"""
mp_translation = self._find_marketplace_translation()
return {
# Title
"title": self.title,
"title_source": mp_translation.title if mp_translation else None,
# Description
"description": self.description,
"description_source": mp_translation.description if mp_translation else None,
# Short Description
"short_description": self.short_description,
"short_description_source": (
mp_translation.short_description if mp_translation else None
),
# Meta Title
"meta_title": self.meta_title,
"meta_title_source": mp_translation.meta_title if mp_translation else None,
# Meta Description
"meta_description": self.meta_description,
"meta_description_source": (
mp_translation.meta_description if mp_translation else None
),
# URL Slug
"url_slug": self.url_slug,
"url_slug_source": mp_translation.url_slug if mp_translation else None,
}