From 3ffa337fcaaa63a5b4c1e9c023a0a061140b49d1 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 28 Jan 2026 22:22:18 +0100 Subject: [PATCH] refactor: convert legacy models/schemas to re-exports Legacy model and schema files now re-export from module locations for backwards compatibility: models/database/: - letzshop.py -> app.modules.marketplace.models - marketplace_import_job.py -> app.modules.marketplace.models - marketplace_product.py -> app.modules.marketplace.models - marketplace_product_translation.py -> app.modules.marketplace.models - subscription.py -> app.modules.billing.models - architecture_scan.py -> app.modules.dev_tools.models - test_run.py -> app.modules.dev_tools.models models/schema/: - marketplace_import_job.py -> app.modules.marketplace.schemas - marketplace_product.py -> app.modules.marketplace.schemas - subscription.py -> app.modules.billing.schemas - stats.py -> app.modules.analytics.schemas This maintains import compatibility while moving actual code to self-contained modules. Co-Authored-By: Claude Opus 4.5 --- models/database/architecture_scan.py | 219 +---- models/database/letzshop.py | 397 +-------- models/database/marketplace_import_job.py | 126 +-- models/database/marketplace_product.py | 316 +------ .../marketplace_product_translation.py | 84 +- models/database/subscription.py | 793 +----------------- models/database/test_run.py | 160 +--- models/schema/marketplace_import_job.py | 194 +---- models/schema/marketplace_product.py | 260 +----- models/schema/stats.py | 382 ++------- models/schema/subscription.py | 255 ++---- 11 files changed, 350 insertions(+), 2836 deletions(-) diff --git a/models/database/architecture_scan.py b/models/database/architecture_scan.py index d7cdefef..d2caf3fe 100644 --- a/models/database/architecture_scan.py +++ b/models/database/architecture_scan.py @@ -1,202 +1,27 @@ +# models/database/architecture_scan.py """ -Architecture Scan Models -Database models for tracking code quality scans and violations +Architecture Scan Models - LEGACY LOCATION + +This file exists for backward compatibility. +The canonical location is now: app/modules/dev_tools/models/architecture_scan.py + +All imports should use the new location: + from app.modules.dev_tools.models import ArchitectureScan, ArchitectureViolation, ... """ -from sqlalchemy import ( - JSON, - Boolean, - Column, - DateTime, - Float, - ForeignKey, - Integer, - String, - Text, +# Re-export from canonical location for backward compatibility +from app.modules.dev_tools.models.architecture_scan import ( + ArchitectureScan, + ArchitectureViolation, + ArchitectureRule, + ViolationAssignment, + ViolationComment, ) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base - - -class ArchitectureScan(Base): - """Represents a single run of a code quality validator""" - - __tablename__ = "architecture_scans" - - id = Column(Integer, primary_key=True, index=True) - timestamp = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False, index=True - ) - validator_type = Column( - String(20), nullable=False, index=True, default="architecture" - ) # 'architecture', 'security', 'performance' - - # Background task status fields (harmonized architecture) - status = Column( - String(30), nullable=False, default="pending", index=True - ) # 'pending', 'running', 'completed', 'failed', 'completed_with_warnings' - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - error_message = Column(Text, nullable=True) - progress_message = Column(String(255), nullable=True) # Current step description - - # Scan results - total_files = Column(Integer, default=0) - total_violations = Column(Integer, default=0) - errors = Column(Integer, default=0) - warnings = Column(Integer, default=0) - duration_seconds = Column(Float, default=0.0) - triggered_by = Column(String(100)) # 'manual:username', 'scheduled', 'ci/cd' - git_commit_hash = Column(String(40)) - - # Celery task tracking (optional - for USE_CELERY=true) - celery_task_id = Column(String(255), nullable=True, index=True) - - # Relationship to violations - violations = relationship( - "ArchitectureViolation", back_populates="scan", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class ArchitectureViolation(Base): - """Represents a single code quality violation found during a scan""" - - __tablename__ = "architecture_violations" - - id = Column(Integer, primary_key=True, index=True) - scan_id = Column( - Integer, ForeignKey("architecture_scans.id"), nullable=False, index=True - ) - validator_type = Column( - String(20), nullable=False, index=True, default="architecture" - ) # 'architecture', 'security', 'performance' - rule_id = Column(String(20), nullable=False, index=True) # e.g., 'API-001', 'SEC-001', 'PERF-001' - rule_name = Column(String(200), nullable=False) - severity = Column( - String(10), nullable=False, index=True - ) # 'error', 'warning', 'info' - file_path = Column(String(500), nullable=False, index=True) - line_number = Column(Integer, nullable=False) - message = Column(Text, nullable=False) - context = Column(Text) # Code snippet - suggestion = Column(Text) - status = Column( - String(20), default="open", index=True - ) # 'open', 'assigned', 'resolved', 'ignored', 'technical_debt' - assigned_to = Column(Integer, ForeignKey("users.id")) - resolved_at = Column(DateTime(timezone=True)) - resolved_by = Column(Integer, ForeignKey("users.id")) - resolution_note = Column(Text) - created_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - # Relationships - scan = relationship("ArchitectureScan", back_populates="violations") - assigned_user = relationship( - "User", foreign_keys=[assigned_to], backref="assigned_violations" - ) - resolver = relationship( - "User", foreign_keys=[resolved_by], backref="resolved_violations" - ) - assignments = relationship( - "ViolationAssignment", back_populates="violation", cascade="all, delete-orphan" - ) - comments = relationship( - "ViolationComment", back_populates="violation", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class ArchitectureRule(Base): - """Code quality rules configuration (from YAML with database overrides)""" - - __tablename__ = "architecture_rules" - - id = Column(Integer, primary_key=True, index=True) - rule_id = Column( - String(20), unique=True, nullable=False, index=True - ) # e.g., 'API-001', 'SEC-001', 'PERF-001' - validator_type = Column( - String(20), nullable=False, index=True, default="architecture" - ) # 'architecture', 'security', 'performance' - category = Column( - String(50), nullable=False - ) # 'api_endpoint', 'service_layer', 'authentication', 'database', etc. - name = Column(String(200), nullable=False) - description = Column(Text) - severity = Column(String(10), nullable=False) # Can override default from YAML - enabled = Column(Boolean, default=True, nullable=False) - custom_config = Column(JSON) # For rule-specific settings - created_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - updated_at = Column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - def __repr__(self): - return f"" - - -class ViolationAssignment(Base): - """Tracks assignment of violations to developers""" - - __tablename__ = "violation_assignments" - - id = Column(Integer, primary_key=True, index=True) - violation_id = Column( - Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True - ) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - assigned_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - assigned_by = Column(Integer, ForeignKey("users.id")) - due_date = Column(DateTime(timezone=True)) - priority = Column( - String(10), default="medium" - ) # 'low', 'medium', 'high', 'critical' - - # Relationships - violation = relationship("ArchitectureViolation", back_populates="assignments") - user = relationship("User", foreign_keys=[user_id], backref="violation_assignments") - assigner = relationship( - "User", foreign_keys=[assigned_by], backref="assigned_by_me" - ) - - def __repr__(self): - return f"" - - -class ViolationComment(Base): - """Comments on violations for collaboration""" - - __tablename__ = "violation_comments" - - id = Column(Integer, primary_key=True, index=True) - violation_id = Column( - Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True - ) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - comment = Column(Text, nullable=False) - created_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - # Relationships - violation = relationship("ArchitectureViolation", back_populates="comments") - user = relationship("User", backref="violation_comments") - - def __repr__(self): - return f"" +__all__ = [ + "ArchitectureScan", + "ArchitectureViolation", + "ArchitectureRule", + "ViolationAssignment", + "ViolationComment", +] diff --git a/models/database/letzshop.py b/models/database/letzshop.py index b09e6540..cb49cf7e 100644 --- a/models/database/letzshop.py +++ b/models/database/letzshop.py @@ -1,379 +1,34 @@ # models/database/letzshop.py """ -Database models for Letzshop marketplace integration. +Legacy location for Letzshop models. -Provides models for: -- VendorLetzshopCredentials: Per-vendor API key storage (encrypted) -- LetzshopFulfillmentQueue: Outbound operation queue with retry -- LetzshopSyncLog: Audit trail for sync operations -- LetzshopHistoricalImportJob: Progress tracking for historical imports +MIGRATED: Models have been moved to app.modules.marketplace.models.letzshop. -Note: Orders are now stored in the unified `orders` table with channel='letzshop'. -The LetzshopOrder model has been removed in favor of the unified Order model. +New location: + from app.modules.marketplace.models import ( + VendorLetzshopCredentials, + LetzshopFulfillmentQueue, + LetzshopVendorCache, + LetzshopSyncLog, + LetzshopHistoricalImportJob, + ) + +This file re-exports from the new location for backward compatibility. """ -from sqlalchemy import ( - Boolean, - Column, - DateTime, - ForeignKey, - Index, - Integer, - String, - Text, +# Re-export from the new canonical location +from app.modules.marketplace.models.letzshop import ( + VendorLetzshopCredentials, + LetzshopFulfillmentQueue, + LetzshopVendorCache, + LetzshopSyncLog, + LetzshopHistoricalImportJob, ) -from sqlalchemy.dialects.sqlite import JSON -from sqlalchemy.orm import relationship -from app.core.database import Base -from models.database.base import TimestampMixin - - -class VendorLetzshopCredentials(Base, TimestampMixin): - """ - Per-vendor Letzshop API credentials. - - Stores encrypted API keys and sync settings for each vendor's - Letzshop integration. - """ - - __tablename__ = "vendor_letzshop_credentials" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column( - Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True - ) - - # Encrypted API credentials - api_key_encrypted = Column(Text, nullable=False) - api_endpoint = Column(String(255), default="https://letzshop.lu/graphql") - - # Sync settings - auto_sync_enabled = Column(Boolean, default=False) - sync_interval_minutes = Column(Integer, default=15) - - # Test mode (disables API mutations when enabled) - test_mode_enabled = Column(Boolean, default=False) - - # Default carrier settings - default_carrier = Column(String(50), nullable=True) # greco, colissimo, xpresslogistics - - # Carrier label URL prefixes - carrier_greco_label_url = Column( - String(500), default="https://dispatchweb.fr/Tracky/Home/" - ) - carrier_colissimo_label_url = Column(String(500), nullable=True) - carrier_xpresslogistics_label_url = Column(String(500), nullable=True) - - # Last sync status - last_sync_at = Column(DateTime(timezone=True), nullable=True) - last_sync_status = Column(String(50), nullable=True) # success, failed, partial - last_sync_error = Column(Text, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="letzshop_credentials") - - def __repr__(self): - return f"" - - -class LetzshopFulfillmentQueue(Base, TimestampMixin): - """ - Queue for outbound fulfillment operations to Letzshop. - - Supports retry logic for failed operations. - References the unified orders table. - """ - - __tablename__ = "letzshop_fulfillment_queue" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True) - - # Operation type - operation = Column( - String(50), nullable=False - ) # confirm_item, decline_item, set_tracking - - # Operation payload - payload = Column(JSON, nullable=False) - - # Status and retry - status = Column( - String(50), default="pending" - ) # pending, processing, completed, failed - attempts = Column(Integer, default=0) - max_attempts = Column(Integer, default=3) - last_attempt_at = Column(DateTime(timezone=True), nullable=True) - next_retry_at = Column(DateTime(timezone=True), nullable=True) - error_message = Column(Text, nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - # Response from Letzshop - response_data = Column(JSON, nullable=True) - - # Relationships - vendor = relationship("Vendor") - order = relationship("Order") - - __table_args__ = ( - Index("idx_fulfillment_queue_status", "status", "vendor_id"), - Index("idx_fulfillment_queue_retry", "status", "next_retry_at"), - Index("idx_fulfillment_queue_order", "order_id"), - ) - - def __repr__(self): - return f"" - - -class LetzshopSyncLog(Base, TimestampMixin): - """ - Audit log for all Letzshop sync operations. - """ - - __tablename__ = "letzshop_sync_logs" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - - # Operation details - operation_type = Column( - String(50), nullable=False - ) # order_import, confirm_inventory, set_tracking, etc. - direction = Column(String(10), nullable=False) # inbound, outbound - - # Status - status = Column(String(50), nullable=False) # success, failed, partial - - # Details - records_processed = Column(Integer, default=0) - records_succeeded = Column(Integer, default=0) - records_failed = Column(Integer, default=0) - error_details = Column(JSON, nullable=True) - - # Timestamps - started_at = Column(DateTime(timezone=True), nullable=False) - completed_at = Column(DateTime(timezone=True), nullable=True) - duration_seconds = Column(Integer, nullable=True) - - # Triggered by - triggered_by = Column(String(100), nullable=True) # user_id, scheduler, webhook - - # Relationships - vendor = relationship("Vendor") - - __table_args__ = ( - Index("idx_sync_log_vendor_type", "vendor_id", "operation_type"), - Index("idx_sync_log_vendor_date", "vendor_id", "started_at"), - ) - - def __repr__(self): - return f"" - - -class LetzshopVendorCache(Base, TimestampMixin): - """ - Cache of Letzshop marketplace vendor directory. - - This table stores vendor data fetched from Letzshop's public GraphQL API, - allowing users to browse and claim existing Letzshop shops during signup. - - Data is periodically synced from Letzshop (e.g., daily via Celery task). - """ - - __tablename__ = "letzshop_vendor_cache" - - id = Column(Integer, primary_key=True, index=True) - - # Letzshop identifiers - letzshop_id = Column(String(50), unique=True, nullable=False, index=True) - """Unique ID from Letzshop (e.g., 'lpkedYMRup').""" - - slug = Column(String(200), unique=True, nullable=False, index=True) - """URL slug (e.g., 'nicks-diecast-corner').""" - - # Basic info - name = Column(String(255), nullable=False) - """Vendor display name.""" - - company_name = Column(String(255), nullable=True) - """Legal company name.""" - - is_active = Column(Boolean, default=True) - """Whether vendor is active on Letzshop.""" - - # Descriptions (multilingual) - description_en = Column(Text, nullable=True) - description_fr = Column(Text, nullable=True) - description_de = Column(Text, nullable=True) - - # Contact information - email = Column(String(255), nullable=True) - phone = Column(String(50), nullable=True) - fax = Column(String(50), nullable=True) - website = Column(String(500), nullable=True) - - # Location - street = Column(String(255), nullable=True) - street_number = Column(String(50), nullable=True) - city = Column(String(100), nullable=True) - zipcode = Column(String(20), nullable=True) - country_iso = Column(String(5), default="LU") - latitude = Column(String(20), nullable=True) - longitude = Column(String(20), nullable=True) - - # Categories (stored as JSON array of names) - categories = Column(JSON, default=list) - """List of category names, e.g., ['Fashion', 'Shoes'].""" - - # Images - background_image_url = Column(String(500), nullable=True) - - # Social media (stored as JSON array of URLs) - social_media_links = Column(JSON, default=list) - """List of social media URLs.""" - - # Opening hours (multilingual text) - opening_hours_en = Column(Text, nullable=True) - opening_hours_fr = Column(Text, nullable=True) - opening_hours_de = Column(Text, nullable=True) - - # Representative - representative_name = Column(String(255), nullable=True) - representative_title = Column(String(100), nullable=True) - - # Claiming status (linked to our platform) - claimed_by_vendor_id = Column( - Integer, ForeignKey("vendors.id"), nullable=True, index=True - ) - """If claimed, links to our Vendor record.""" - - claimed_at = Column(DateTime(timezone=True), nullable=True) - """When the vendor was claimed on our platform.""" - - # Sync metadata - last_synced_at = Column(DateTime(timezone=True), nullable=False) - """When this record was last updated from Letzshop.""" - - raw_data = Column(JSON, nullable=True) - """Full raw response from Letzshop API for reference.""" - - # Relationship to claimed vendor - claimed_vendor = relationship("Vendor", foreign_keys=[claimed_by_vendor_id]) - - __table_args__ = ( - Index("idx_vendor_cache_city", "city"), - Index("idx_vendor_cache_claimed", "claimed_by_vendor_id"), - Index("idx_vendor_cache_active", "is_active"), - ) - - def __repr__(self): - return f"" - - @property - def is_claimed(self) -> bool: - """Check if this vendor has been claimed on our platform.""" - return self.claimed_by_vendor_id is not None - - @property - def letzshop_url(self) -> str: - """Get the Letzshop profile URL.""" - return f"https://letzshop.lu/vendors/{self.slug}" - - def get_description(self, lang: str = "en") -> str | None: - """Get description in specified language with fallback.""" - descriptions = { - "en": self.description_en, - "fr": self.description_fr, - "de": self.description_de, - } - # Try requested language, then fallback order - for try_lang in [lang, "en", "fr", "de"]: - if descriptions.get(try_lang): - return descriptions[try_lang] - return None - - def get_opening_hours(self, lang: str = "en") -> str | None: - """Get opening hours in specified language with fallback.""" - hours = { - "en": self.opening_hours_en, - "fr": self.opening_hours_fr, - "de": self.opening_hours_de, - } - for try_lang in [lang, "en", "fr", "de"]: - if hours.get(try_lang): - return hours[try_lang] - return None - - def get_full_address(self) -> str | None: - """Get formatted full address.""" - parts = [] - if self.street: - addr = self.street - if self.street_number: - addr += f" {self.street_number}" - parts.append(addr) - if self.zipcode or self.city: - parts.append(f"{self.zipcode or ''} {self.city or ''}".strip()) - return ", ".join(parts) if parts else None - - -class LetzshopHistoricalImportJob(Base, TimestampMixin): - """ - Track progress of historical order imports from Letzshop. - - Enables real-time progress tracking via polling for long-running imports. - """ - - __tablename__ = "letzshop_historical_import_jobs" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - # Status: pending | fetching | processing | completed | failed - status = Column(String(50), default="pending", nullable=False) - - # Current phase: "confirmed" | "declined" - current_phase = Column(String(20), nullable=True) - - # Fetch progress - current_page = Column(Integer, default=0) - total_pages = Column(Integer, nullable=True) # null = unknown yet - shipments_fetched = Column(Integer, default=0) - - # Processing progress - orders_processed = Column(Integer, default=0) - orders_imported = Column(Integer, default=0) - orders_updated = Column(Integer, default=0) - orders_skipped = Column(Integer, default=0) - - # EAN matching stats - products_matched = Column(Integer, default=0) - products_not_found = Column(Integer, default=0) - - # Phase-specific stats (stored as JSON for combining confirmed + declined) - confirmed_stats = Column(JSON, nullable=True) - declined_stats = Column(JSON, nullable=True) - - # Error handling - error_message = Column(Text, nullable=True) - - # Celery task tracking (optional - for USE_CELERY=true) - celery_task_id = Column(String(255), nullable=True, index=True) - - # Timing - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - # Relationships - vendor = relationship("Vendor") - user = relationship("User") - - __table_args__ = ( - Index("idx_historical_import_vendor", "vendor_id", "status"), - ) - - def __repr__(self): - return f"" +__all__ = [ + "VendorLetzshopCredentials", + "LetzshopFulfillmentQueue", + "LetzshopVendorCache", + "LetzshopSyncLog", + "LetzshopHistoricalImportJob", +] diff --git a/models/database/marketplace_import_job.py b/models/database/marketplace_import_job.py index 6992f00b..a84f2c51 100644 --- a/models/database/marketplace_import_job.py +++ b/models/database/marketplace_import_job.py @@ -1,116 +1,22 @@ -from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Text -from sqlalchemy.orm import relationship +# models/database/marketplace_import_job.py +""" +Legacy location for marketplace import job models. -from app.core.database import Base -from models.database.base import TimestampMixin +MIGRATED: Models have been moved to app.modules.marketplace.models.marketplace_import_job. - -class MarketplaceImportError(Base, TimestampMixin): - """ - Stores detailed information about individual import errors. - - Each row that fails during import creates an error record with: - - Row number from the source file - - Identifier (marketplace_product_id if available) - - Error type and message - - Raw row data for review - """ - - __tablename__ = "marketplace_import_errors" - - id = Column(Integer, primary_key=True, index=True) - import_job_id = Column( - Integer, - ForeignKey("marketplace_import_jobs.id", ondelete="CASCADE"), - nullable=False, - index=True, +New location: + from app.modules.marketplace.models import ( + MarketplaceImportJob, + MarketplaceImportError, ) - # Error location - row_number = Column(Integer, nullable=False) +This file re-exports from the new location for backward compatibility. +""" - # Identifier from the row (if available) - identifier = Column(String) # marketplace_product_id, gtin, mpn, etc. +# Re-export from the new canonical location +from app.modules.marketplace.models.marketplace_import_job import ( + MarketplaceImportJob, + MarketplaceImportError, +) - # Error details - error_type = Column( - String(50), nullable=False - ) # missing_title, missing_id, parse_error, etc. - error_message = Column(Text, nullable=False) - - # Raw row data for review (JSON) - row_data = Column(JSON) - - # Relationship - import_job = relationship("MarketplaceImportJob", back_populates="errors") - - __table_args__ = ( - Index("idx_import_error_job_id", "import_job_id"), - Index("idx_import_error_type", "error_type"), - ) - - def __repr__(self): - return ( - f"" - ) - - -class MarketplaceImportJob(Base, TimestampMixin): - __tablename__ = "marketplace_import_jobs" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - # Import configuration - marketplace = Column(String, nullable=False, index=True, default="Letzshop") - source_url = Column(String, nullable=False) - language = Column( - String(5), nullable=False, default="en" - ) # Language for translations - - # Status tracking - status = Column( - String, nullable=False, default="pending" - ) # pending, processing, completed, failed, completed_with_errors - - # Results - imported_count = Column(Integer, default=0) - updated_count = Column(Integer, default=0) - error_count = Column(Integer, default=0) - total_processed = Column(Integer, default=0) - - # Error handling - error_message = Column(Text) - - # Celery task tracking (optional - for USE_CELERY=true) - celery_task_id = Column(String(255), nullable=True, index=True) - - # Timestamps - started_at = Column(DateTime(timezone=True)) - completed_at = Column(DateTime(timezone=True)) - - # Relationships - vendor = relationship("Vendor", back_populates="marketplace_import_jobs") - user = relationship("User", foreign_keys=[user_id]) - errors = relationship( - "MarketplaceImportError", - back_populates="import_job", - cascade="all, delete-orphan", - order_by="MarketplaceImportError.row_number", - ) - - # Indexes for performance - __table_args__ = ( - Index("idx_import_vendor_status", "vendor_id", "status"), - Index("idx_import_vendor_created", "vendor_id", "created_at"), - Index("idx_import_user_marketplace", "user_id", "marketplace"), - ) - - def __repr__(self): - return ( - f"" - ) +__all__ = ["MarketplaceImportJob", "MarketplaceImportError"] diff --git a/models/database/marketplace_product.py b/models/database/marketplace_product.py index 90ab4973..dacec6c2 100644 --- a/models/database/marketplace_product.py +++ b/models/database/marketplace_product.py @@ -1,300 +1,28 @@ -"""Marketplace Product model for multi-marketplace product integration. +# models/database/marketplace_product.py +""" +Legacy location for marketplace product model. -This model stores canonical product data from various marketplaces (Letzshop, -Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports: -- Physical and digital products -- Multi-language translations (via MarketplaceProductTranslation) -- Flexible attributes for marketplace-specific data -- Google Shopping fields for Letzshop compatibility +MIGRATED: All models have been moved to app.modules.marketplace.models.marketplace_product. -Money values are stored as integer cents (e.g., €105.91 = 10591). -Weight is stored as integer grams (e.g., 1.5kg = 1500g). -See docs/architecture/money-handling.md for details. +New location: + from app.modules.marketplace.models import ( + MarketplaceProduct, + ProductType, + DigitalDeliveryMethod, + ) + +This file re-exports from the new location for backward compatibility. """ -from enum import Enum - -from sqlalchemy import ( - Boolean, - Column, - Index, - Integer, - String, +# Re-export everything from the new canonical location +from app.modules.marketplace.models.marketplace_product import ( + MarketplaceProduct, + ProductType, + DigitalDeliveryMethod, ) -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 ProductType(str, Enum): - """Product type classification.""" - - PHYSICAL = "physical" - DIGITAL = "digital" - SERVICE = "service" - SUBSCRIPTION = "subscription" - - -class DigitalDeliveryMethod(str, Enum): - """Digital product delivery methods.""" - - DOWNLOAD = "download" - EMAIL = "email" - IN_APP = "in_app" - STREAMING = "streaming" - LICENSE_KEY = "license_key" - - -class MarketplaceProduct(Base, TimestampMixin): - """Canonical product data from marketplace sources. - - This table stores normalized product information from all marketplace sources. - Localized content (title, description) is stored in MarketplaceProductTranslation. - - Price fields use integer cents for precision (€19.99 = 1999 cents). - Weight uses integer grams (1.5kg = 1500 grams). - """ - - __tablename__ = "marketplace_products" - - id = Column(Integer, primary_key=True, index=True) - - # === UNIVERSAL IDENTIFIERS === - marketplace_product_id = Column(String, unique=True, index=True, nullable=False) - gtin = Column(String, index=True) # EAN/UPC - primary cross-marketplace matching - mpn = Column(String, index=True) # Manufacturer Part Number - sku = Column(String, index=True) # Internal SKU if assigned - - # === SOURCE TRACKING === - marketplace = Column( - String, index=True, nullable=True, default="letzshop" - ) # 'letzshop', 'amazon', 'ebay', 'codeswholesale' - source_url = Column(String) # Original product URL - vendor_name = Column(String, index=True) # Seller/vendor in marketplace - - # === PRODUCT TYPE === - product_type_enum = Column( - String(20), nullable=False, default=ProductType.PHYSICAL.value - ) - is_digital = Column(Boolean, default=False, index=True) - - # === DIGITAL PRODUCT FIELDS === - digital_delivery_method = Column(String(20)) # DigitalDeliveryMethod values - platform = Column(String(50), index=True) # 'steam', 'playstation', 'xbox', etc. - region_restrictions = Column(JSON) # ["EU", "US"] or null for global - license_type = Column(String(50)) # 'single_use', 'subscription', 'lifetime' - - # === NON-LOCALIZED FIELDS === - brand = Column(String, index=True) - google_product_category = Column(String, index=True) - category_path = Column(String) # Normalized category hierarchy - condition = Column(String) - - # === PRICING (stored as integer cents) === - price = Column(String) # Raw price string "19.99 EUR" (kept for reference) - price_cents = Column(Integer) # Parsed numeric price in cents - sale_price = Column(String) # Raw sale price string - sale_price_cents = Column(Integer) # Parsed numeric sale price in cents - currency = Column(String(3), default="EUR") - - # === TAX / VAT === - # Luxembourg VAT rates: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard) - # Prices are stored as gross (VAT-inclusive). Default to standard rate. - tax_rate_percent = Column(Integer, default=17, nullable=False) - - # === MEDIA === - image_link = Column(String) - additional_image_link = Column(String) # Legacy single string - additional_images = Column(JSON) # Array of image URLs - - # === PRODUCT ATTRIBUTES (Flexible) === - attributes = Column(JSON) # {color, size, material, etc.} - - # === PHYSICAL PRODUCT FIELDS === - weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500) - weight_unit = Column(String(10), default="kg") # Display unit - dimensions = Column(JSON) # {length, width, height, unit} - - # === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) === - link = Column(String) - availability = Column(String, index=True) - adult = Column(String) - multipack = Column(Integer) - is_bundle = Column(String) - age_group = Column(String) - color = Column(String) - gender = Column(String) - material = Column(String) - pattern = Column(String) - size = Column(String) - size_type = Column(String) - size_system = Column(String) - item_group_id = Column(String) - product_type_raw = Column(String) # Original feed value (renamed from product_type) - custom_label_0 = Column(String) - custom_label_1 = Column(String) - custom_label_2 = Column(String) - custom_label_3 = Column(String) - custom_label_4 = Column(String) - unit_pricing_measure = Column(String) - unit_pricing_base_measure = Column(String) - identifier_exists = Column(String) - shipping = Column(String) - - # === STATUS === - is_active = Column(Boolean, default=True, index=True) - - # === RELATIONSHIPS === - translations = relationship( - "MarketplaceProductTranslation", - back_populates="marketplace_product", - cascade="all, delete-orphan", - ) - vendor_products = relationship("Product", back_populates="marketplace_product") - - # === INDEXES === - __table_args__ = ( - Index("idx_marketplace_vendor", "marketplace", "vendor_name"), - Index("idx_marketplace_brand", "marketplace", "brand"), - Index("idx_mp_gtin_marketplace", "gtin", "marketplace"), - Index("idx_mp_product_type", "product_type_enum", "is_digital"), - ) - - def __repr__(self): - return ( - f"" - ) - - # === PRICE PROPERTIES (Euro convenience accessors) === - - @property - def price_numeric(self) -> float | None: - """Get price in euros (for API/display). Legacy name for compatibility.""" - if self.price_cents is not None: - return cents_to_euros(self.price_cents) - return None - - @price_numeric.setter - def price_numeric(self, value: float | None): - """Set price from euros. Legacy name for compatibility.""" - self.price_cents = euros_to_cents(value) if value is not None else None - - @property - def sale_price_numeric(self) -> float | None: - """Get sale price in euros (for API/display). Legacy name for compatibility.""" - if self.sale_price_cents is not None: - return cents_to_euros(self.sale_price_cents) - return None - - @sale_price_numeric.setter - def sale_price_numeric(self, value: float | None): - """Set sale price from euros. Legacy name for compatibility.""" - self.sale_price_cents = euros_to_cents(value) if value is not None else None - - @property - def weight(self) -> float | None: - """Get weight in kg (for API/display).""" - if self.weight_grams is not None: - return self.weight_grams / 1000.0 - return None - - @weight.setter - def weight(self, value: float | None): - """Set weight from kg.""" - self.weight_grams = int(value * 1000) if value is not None else None - - # === HELPER PROPERTIES === - - @property - def product_type(self) -> ProductType: - """Get product type as enum.""" - return ProductType(self.product_type_enum) - - @product_type.setter - def product_type(self, value: ProductType | str): - """Set product type from enum or string.""" - if isinstance(value, ProductType): - self.product_type_enum = value.value - else: - self.product_type_enum = value - - @property - def delivery_method(self) -> DigitalDeliveryMethod | None: - """Get digital delivery method as enum.""" - if self.digital_delivery_method: - return DigitalDeliveryMethod(self.digital_delivery_method) - return None - - @delivery_method.setter - def delivery_method(self, value: DigitalDeliveryMethod | str | None): - """Set delivery method from enum or string.""" - if value is None: - self.digital_delivery_method = None - elif isinstance(value, DigitalDeliveryMethod): - self.digital_delivery_method = value.value - else: - self.digital_delivery_method = value - - def get_translation(self, language: str) -> "MarketplaceProductTranslation | 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 with fallback to 'en'.""" - translation = self.get_translation(language) - if translation: - return translation.title - - # Fallback to English - if language != "en": - en_translation = self.get_translation("en") - if en_translation: - return en_translation.title - - return None - - def get_description(self, language: str = "en") -> str | None: - """Get description for a specific language with fallback to 'en'.""" - translation = self.get_translation(language) - if translation: - return translation.description - - # Fallback to English - if language != "en": - en_translation = self.get_translation("en") - if en_translation: - return en_translation.description - - return None - - @property - def effective_price(self) -> float | None: - """Get the effective numeric price in euros.""" - return self.price_numeric - - @property - def effective_sale_price(self) -> float | None: - """Get the effective numeric sale price in euros.""" - return self.sale_price_numeric - - @property - def all_images(self) -> list[str]: - """Get all product images as a list.""" - images = [] - if self.image_link: - images.append(self.image_link) - if self.additional_images: - images.extend(self.additional_images) - elif self.additional_image_link: - # Legacy single string format - images.append(self.additional_image_link) - return images +__all__ = [ + "MarketplaceProduct", + "ProductType", + "DigitalDeliveryMethod", +] diff --git a/models/database/marketplace_product_translation.py b/models/database/marketplace_product_translation.py index 9778ac35..d9a96fd4 100644 --- a/models/database/marketplace_product_translation.py +++ b/models/database/marketplace_product_translation.py @@ -1,76 +1,18 @@ -"""Marketplace Product Translation model for multi-language support. +# models/database/marketplace_product_translation.py +""" +Legacy location for marketplace product translation model. -This model stores localized content (title, description, SEO fields) for -marketplace products. Each marketplace product can have multiple translations -for different languages. +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. """ -from sqlalchemy import ( - Column, - ForeignKey, - Index, - Integer, - String, - Text, - UniqueConstraint, +# Re-export from the new canonical location +from app.modules.marketplace.models.marketplace_product_translation import ( + MarketplaceProductTranslation, ) -from sqlalchemy.orm import relationship -from app.core.database import Base -from models.database.base import TimestampMixin - - -class MarketplaceProductTranslation(Base, TimestampMixin): - """Localized content for marketplace products. - - Stores translations for product titles, descriptions, and SEO fields. - Each marketplace_product can have one translation per language. - """ - - __tablename__ = "marketplace_product_translations" - - id = Column(Integer, primary_key=True, index=True) - marketplace_product_id = Column( - Integer, - ForeignKey("marketplace_products.id", ondelete="CASCADE"), - nullable=False, - ) - language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb' - - # === LOCALIZED CONTENT === - title = Column(String, nullable=False) - 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)) - - # === SOURCE TRACKING === - source_import_id = Column(Integer) # Which import job provided this - source_file = Column(String) # e.g., "letzshop_fr.csv" - - # === RELATIONSHIPS === - marketplace_product = relationship( - "MarketplaceProduct", - back_populates="translations", - ) - - __table_args__ = ( - UniqueConstraint( - "marketplace_product_id", - "language", - name="uq_marketplace_product_translation", - ), - Index("idx_mpt_marketplace_product_id", "marketplace_product_id"), - Index("idx_mpt_language", "language"), - ) - - def __repr__(self): - return ( - f"" - ) +__all__ = ["MarketplaceProductTranslation"] diff --git a/models/database/subscription.py b/models/database/subscription.py index d4a9594a..c40ea6db 100644 --- a/models/database/subscription.py +++ b/models/database/subscription.py @@ -1,756 +1,53 @@ # models/database/subscription.py """ -Subscription database models for tier-based access control. +Legacy location for subscription models. -Provides models for: -- SubscriptionTier: Database-driven tier definitions with Stripe integration -- VendorSubscription: Per-vendor subscription tracking -- AddOnProduct: Purchasable add-ons (domains, SSL, email packages) -- VendorAddOn: Add-ons purchased by each vendor -- StripeWebhookEvent: Idempotency tracking for webhook processing -- BillingHistory: Invoice and payment history +MIGRATED: All models have been moved to app.modules.billing.models.subscription. -Tier Structure: -- Essential (€49/mo): 100 orders/mo, 200 products, 1 user, LU invoicing -- Professional (€99/mo): 500 orders/mo, unlimited products, 3 users, EU VAT -- Business (€199/mo): 2000 orders/mo, unlimited products, 10 users, analytics, API -- Enterprise (€399+/mo): Unlimited, white-label, custom integrations +New location: + from app.modules.billing.models import ( + VendorSubscription, + SubscriptionTier, + TierCode, + SubscriptionStatus, + ) + +This file re-exports from the new location for backward compatibility. """ -import enum -from datetime import UTC, datetime - -from sqlalchemy import ( - Boolean, - Column, - DateTime, - ForeignKey, - Index, - Integer, - Numeric, - String, - Text, +# Re-export everything from the new canonical location +from app.modules.billing.models.subscription import ( + # Enums + TierCode, + SubscriptionStatus, + AddOnCategory, + BillingPeriod, + # Models + SubscriptionTier, + AddOnProduct, + VendorAddOn, + StripeWebhookEvent, + BillingHistory, + VendorSubscription, + CapacitySnapshot, + # Legacy constants + TIER_LIMITS, ) -from sqlalchemy.dialects.sqlite import JSON -from sqlalchemy.orm import relationship -from app.core.database import Base -from models.database.base import TimestampMixin - - -class TierCode(str, enum.Enum): - """Subscription tier codes.""" - - ESSENTIAL = "essential" - PROFESSIONAL = "professional" - BUSINESS = "business" - ENTERPRISE = "enterprise" - - -class SubscriptionStatus(str, enum.Enum): - """Subscription status.""" - - TRIAL = "trial" # Free trial period - ACTIVE = "active" # Paid and active - PAST_DUE = "past_due" # Payment failed, grace period - CANCELLED = "cancelled" # Cancelled, access until period end - EXPIRED = "expired" # No longer active - - -class AddOnCategory(str, enum.Enum): - """Add-on product categories.""" - - DOMAIN = "domain" - SSL = "ssl" - EMAIL = "email" - STORAGE = "storage" - - -class BillingPeriod(str, enum.Enum): - """Billing period for add-ons.""" - - MONTHLY = "monthly" - ANNUAL = "annual" - ONE_TIME = "one_time" - - -# ============================================================================ -# SubscriptionTier - Database-driven tier definitions -# ============================================================================ - - -class SubscriptionTier(Base, TimestampMixin): - """ - Database-driven tier definitions with Stripe integration. - - Replaces the hardcoded TIER_LIMITS dict for dynamic tier management. - - Can be: - - Global tier (platform_id=NULL): Available to all platforms - - Platform-specific tier (platform_id set): Only for that platform - """ - - __tablename__ = "subscription_tiers" - - id = Column(Integer, primary_key=True, index=True) - - # Platform association (NULL = global tier available to all platforms) - platform_id = Column( - Integer, - ForeignKey("platforms.id", ondelete="CASCADE"), - nullable=True, - index=True, - comment="Platform this tier belongs to (NULL = global tier)", - ) - - code = Column(String(30), nullable=False, index=True) - name = Column(String(100), nullable=False) - description = Column(Text, nullable=True) - - # Pricing (in cents for precision) - price_monthly_cents = Column(Integer, nullable=False) - price_annual_cents = Column(Integer, nullable=True) # Null for enterprise/custom - - # Limits (null = unlimited) - orders_per_month = Column(Integer, nullable=True) - products_limit = Column(Integer, nullable=True) - team_members = Column(Integer, nullable=True) - order_history_months = Column(Integer, nullable=True) - - # CMS Limits (null = unlimited) - cms_pages_limit = Column( - Integer, - nullable=True, - comment="Total CMS pages limit (NULL = unlimited)", - ) - cms_custom_pages_limit = Column( - Integer, - nullable=True, - comment="Custom pages limit, excluding overrides (NULL = unlimited)", - ) - - # Features (JSON array of feature codes) - features = Column(JSON, default=list) - - # Stripe Product/Price IDs - stripe_product_id = Column(String(100), nullable=True) - stripe_price_monthly_id = Column(String(100), nullable=True) - stripe_price_annual_id = Column(String(100), nullable=True) - - # Display and visibility - display_order = Column(Integer, default=0) - is_active = Column(Boolean, default=True, nullable=False) - is_public = Column(Boolean, default=True, nullable=False) # False for enterprise - - # Relationship to Platform - platform = relationship( - "Platform", - back_populates="subscription_tiers", - foreign_keys=[platform_id], - ) - - # Unique constraint: tier code must be unique per platform (or globally if NULL) - __table_args__ = ( - Index("idx_tier_platform_active", "platform_id", "is_active"), - ) - - def __repr__(self): - platform_info = f", platform_id={self.platform_id}" if self.platform_id else "" - return f"" - - def to_dict(self) -> dict: - """Convert tier to dictionary (compatible with TIER_LIMITS format).""" - return { - "name": self.name, - "price_monthly_cents": self.price_monthly_cents, - "price_annual_cents": self.price_annual_cents, - "orders_per_month": self.orders_per_month, - "products_limit": self.products_limit, - "team_members": self.team_members, - "order_history_months": self.order_history_months, - "cms_pages_limit": self.cms_pages_limit, - "cms_custom_pages_limit": self.cms_custom_pages_limit, - "features": self.features or [], - } - - -# ============================================================================ -# AddOnProduct - Purchasable add-ons -# ============================================================================ - - -class AddOnProduct(Base, TimestampMixin): - """ - Purchasable add-on products (domains, SSL, email packages). - - These are separate from subscription tiers and can be added to any tier. - """ - - __tablename__ = "addon_products" - - id = Column(Integer, primary_key=True, index=True) - code = Column(String(50), unique=True, nullable=False, index=True) - name = Column(String(100), nullable=False) - description = Column(Text, nullable=True) - category = Column(String(50), nullable=False, index=True) - - # Pricing - price_cents = Column(Integer, nullable=False) - billing_period = Column( - String(20), default=BillingPeriod.MONTHLY.value, nullable=False - ) - - # For tiered add-ons (e.g., email_5, email_10) - quantity_unit = Column(String(50), nullable=True) # emails, GB, etc. - quantity_value = Column(Integer, nullable=True) # 5, 10, 50, etc. - - # Stripe - stripe_product_id = Column(String(100), nullable=True) - stripe_price_id = Column(String(100), nullable=True) - - # Display - display_order = Column(Integer, default=0) - is_active = Column(Boolean, default=True, nullable=False) - - def __repr__(self): - return f"" - - -# ============================================================================ -# VendorAddOn - Add-ons purchased by vendor -# ============================================================================ - - -class VendorAddOn(Base, TimestampMixin): - """ - Add-ons purchased by a vendor. - - Tracks active add-on subscriptions and their billing status. - """ - - __tablename__ = "vendor_addons" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - addon_product_id = Column( - Integer, ForeignKey("addon_products.id"), nullable=False, index=True - ) - - # Status - status = Column(String(20), default="active", nullable=False, index=True) - - # For domains: store the actual domain name - domain_name = Column(String(255), nullable=True, index=True) - - # Quantity (for tiered add-ons like email packages) - quantity = Column(Integer, default=1, nullable=False) - - # Stripe billing - stripe_subscription_item_id = Column(String(100), nullable=True) - - # Period tracking - period_start = Column(DateTime(timezone=True), nullable=True) - period_end = Column(DateTime(timezone=True), nullable=True) - - # Cancellation - cancelled_at = Column(DateTime(timezone=True), nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="addons") - addon_product = relationship("AddOnProduct") - - __table_args__ = ( - Index("idx_vendor_addon_status", "vendor_id", "status"), - Index("idx_vendor_addon_product", "vendor_id", "addon_product_id"), - ) - - def __repr__(self): - return f"" - - -# ============================================================================ -# StripeWebhookEvent - Webhook idempotency tracking -# ============================================================================ - - -class StripeWebhookEvent(Base, TimestampMixin): - """ - Log of processed Stripe webhook events for idempotency. - - Prevents duplicate processing of the same event. - """ - - __tablename__ = "stripe_webhook_events" - - id = Column(Integer, primary_key=True, index=True) - event_id = Column(String(100), unique=True, nullable=False, index=True) - event_type = Column(String(100), nullable=False, index=True) - - # Processing status - status = Column(String(20), default="pending", nullable=False, index=True) - processed_at = Column(DateTime(timezone=True), nullable=True) - error_message = Column(Text, nullable=True) - - # Raw event data (encrypted for security) - payload_encrypted = Column(Text, nullable=True) - - # Related entities (for quick lookup) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True, index=True) - subscription_id = Column( - Integer, ForeignKey("vendor_subscriptions.id"), nullable=True, index=True - ) - - __table_args__ = (Index("idx_webhook_event_type_status", "event_type", "status"),) - - def __repr__(self): - return f"" - - -# ============================================================================ -# BillingHistory - Invoice and payment history -# ============================================================================ - - -class BillingHistory(Base, TimestampMixin): - """ - Invoice and payment history for vendors. - - Stores Stripe invoice data for display and reporting. - """ - - __tablename__ = "billing_history" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True) - - # Stripe references - stripe_invoice_id = Column(String(100), unique=True, nullable=True, index=True) - stripe_payment_intent_id = Column(String(100), nullable=True) - - # Invoice details - invoice_number = Column(String(50), nullable=True) - invoice_date = Column(DateTime(timezone=True), nullable=False) - due_date = Column(DateTime(timezone=True), nullable=True) - - # Amounts (in cents for precision) - subtotal_cents = Column(Integer, nullable=False) - tax_cents = Column(Integer, default=0, nullable=False) - total_cents = Column(Integer, nullable=False) - amount_paid_cents = Column(Integer, default=0, nullable=False) - currency = Column(String(3), default="EUR", nullable=False) - - # Status - status = Column(String(20), nullable=False, index=True) - - # PDF URLs - invoice_pdf_url = Column(String(500), nullable=True) - hosted_invoice_url = Column(String(500), nullable=True) - - # Description and line items - description = Column(Text, nullable=True) - line_items = Column(JSON, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="billing_history") - - __table_args__ = ( - Index("idx_billing_vendor_date", "vendor_id", "invoice_date"), - Index("idx_billing_status", "vendor_id", "status"), - ) - - def __repr__(self): - return f"" - - -# ============================================================================ -# Legacy TIER_LIMITS (kept for backward compatibility during migration) -# ============================================================================ - -# Tier limit definitions (hardcoded for now, could be moved to DB) -TIER_LIMITS = { - TierCode.ESSENTIAL: { - "name": "Essential", - "price_monthly_cents": 4900, # €49 - "price_annual_cents": 49000, # €490 (2 months free) - "orders_per_month": 100, - "products_limit": 200, - "team_members": 1, - "order_history_months": 6, - "features": [ - "letzshop_sync", - "inventory_basic", - "invoice_lu", - "customer_view", - ], - }, - TierCode.PROFESSIONAL: { - "name": "Professional", - "price_monthly_cents": 9900, # €99 - "price_annual_cents": 99000, # €990 - "orders_per_month": 500, - "products_limit": None, # Unlimited - "team_members": 3, - "order_history_months": 24, - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "customer_view", - "customer_export", - ], - }, - TierCode.BUSINESS: { - "name": "Business", - "price_monthly_cents": 19900, # €199 - "price_annual_cents": 199000, # €1990 - "orders_per_month": 2000, - "products_limit": None, # Unlimited - "team_members": 10, - "order_history_months": None, # Unlimited - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "customer_view", - "customer_export", - "analytics_dashboard", - "accounting_export", - "api_access", - "automation_rules", - "team_roles", - ], - }, - TierCode.ENTERPRISE: { - "name": "Enterprise", - "price_monthly_cents": 39900, # €399 starting - "price_annual_cents": None, # Custom - "orders_per_month": None, # Unlimited - "products_limit": None, # Unlimited - "team_members": None, # Unlimited - "order_history_months": None, # Unlimited - "features": [ - "letzshop_sync", - "inventory_locations", - "inventory_purchase_orders", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "customer_view", - "customer_export", - "analytics_dashboard", - "accounting_export", - "api_access", - "automation_rules", - "team_roles", - "white_label", - "multi_vendor", - "custom_integrations", - "sla_guarantee", - "dedicated_support", - ], - }, -} - - -class VendorSubscription(Base, TimestampMixin): - """ - Per-vendor subscription tracking. - - Tracks the vendor's subscription tier, billing period, - and usage counters for limit enforcement. - """ - - __tablename__ = "vendor_subscriptions" - - id = Column(Integer, primary_key=True, index=True) - vendor_id = Column( - Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True - ) - - # Tier - tier_id is the FK, tier (code) kept for backwards compatibility - tier_id = Column( - Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True - ) - tier = Column( - String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True - ) - - # Status - status = Column( - String(20), default=SubscriptionStatus.TRIAL.value, nullable=False, index=True - ) - - # Billing period - period_start = Column(DateTime(timezone=True), nullable=False) - period_end = Column(DateTime(timezone=True), nullable=False) - is_annual = Column(Boolean, default=False, nullable=False) - - # Trial info - trial_ends_at = Column(DateTime(timezone=True), nullable=True) - - # Card collection tracking (for trials that require card upfront) - card_collected_at = Column(DateTime(timezone=True), nullable=True) - - # Usage counters (reset each billing period) - orders_this_period = Column(Integer, default=0, nullable=False) - orders_limit_reached_at = Column(DateTime(timezone=True), nullable=True) - - # Overrides (for custom enterprise deals) - custom_orders_limit = Column(Integer, nullable=True) # Override tier limit - custom_products_limit = Column(Integer, nullable=True) - custom_team_limit = Column(Integer, nullable=True) - - # Payment info (Stripe integration) - stripe_customer_id = Column(String(100), nullable=True, index=True) - stripe_subscription_id = Column(String(100), nullable=True, index=True) - stripe_price_id = Column(String(100), nullable=True) # Current price being billed - stripe_payment_method_id = Column(String(100), nullable=True) # Default payment method - - # Proration and upgrade/downgrade tracking - proration_behavior = Column(String(50), default="create_prorations") - scheduled_tier_change = Column(String(30), nullable=True) # Pending tier change - scheduled_change_at = Column(DateTime(timezone=True), nullable=True) - - # Payment failure tracking - payment_retry_count = Column(Integer, default=0, nullable=False) - last_payment_error = Column(Text, nullable=True) - - # Cancellation - cancelled_at = Column(DateTime(timezone=True), nullable=True) - cancellation_reason = Column(Text, nullable=True) - - # Relationships - vendor = relationship("Vendor", back_populates="subscription") - tier_obj = relationship("SubscriptionTier", backref="subscriptions") - - __table_args__ = ( - Index("idx_subscription_vendor_status", "vendor_id", "status"), - Index("idx_subscription_period", "period_start", "period_end"), - ) - - def __repr__(self): - return f"" - - # ========================================================================= - # Tier Limit Properties - # ========================================================================= - - @property - def tier_limits(self) -> dict: - """Get the limit definitions for current tier. - - Uses database tier (tier_obj) if available, otherwise falls back - to hardcoded TIER_LIMITS for backwards compatibility. - """ - # Use database tier if relationship is loaded - if self.tier_obj is not None: - return { - "orders_per_month": self.tier_obj.orders_per_month, - "products_limit": self.tier_obj.products_limit, - "team_members": self.tier_obj.team_members, - "features": self.tier_obj.features or [], - } - # Fall back to hardcoded limits - return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL]) - - @property - def orders_limit(self) -> int | None: - """Get effective orders limit (custom or tier default).""" - if self.custom_orders_limit is not None: - return self.custom_orders_limit - return self.tier_limits.get("orders_per_month") - - @property - def products_limit(self) -> int | None: - """Get effective products limit (custom or tier default).""" - if self.custom_products_limit is not None: - return self.custom_products_limit - return self.tier_limits.get("products_limit") - - @property - def team_members_limit(self) -> int | None: - """Get effective team members limit (custom or tier default).""" - if self.custom_team_limit is not None: - return self.custom_team_limit - return self.tier_limits.get("team_members") - - @property - def features(self) -> list[str]: - """Get list of enabled features for current tier.""" - return self.tier_limits.get("features", []) - - # ========================================================================= - # Status Checks - # ========================================================================= - - @property - def is_active(self) -> bool: - """Check if subscription allows access.""" - return self.status in [ - SubscriptionStatus.TRIAL.value, - SubscriptionStatus.ACTIVE.value, - SubscriptionStatus.PAST_DUE.value, # Grace period - SubscriptionStatus.CANCELLED.value, # Until period end - ] - - @property - def is_trial(self) -> bool: - """Check if currently in trial.""" - return self.status == SubscriptionStatus.TRIAL.value - - @property - def trial_days_remaining(self) -> int | None: - """Get remaining trial days.""" - if not self.is_trial or not self.trial_ends_at: - return None - remaining = (self.trial_ends_at - datetime.now(UTC)).days - return max(0, remaining) - - # ========================================================================= - # Limit Checks - # ========================================================================= - - def can_create_order(self) -> tuple[bool, str | None]: - """ - Check if vendor can create/import another order. - - Returns: (can_create, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.orders_limit - if limit is None: # Unlimited - return True, None - - if self.orders_this_period >= limit: - return False, f"Monthly order limit reached ({limit} orders). Upgrade to continue." - - return True, None - - def can_add_product(self, current_count: int) -> tuple[bool, str | None]: - """ - Check if vendor can add another product. - - Args: - current_count: Current number of products - - Returns: (can_add, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.products_limit - if limit is None: # Unlimited - return True, None - - if current_count >= limit: - return False, f"Product limit reached ({limit} products). Upgrade to add more." - - return True, None - - def can_add_team_member(self, current_count: int) -> tuple[bool, str | None]: - """ - Check if vendor can add another team member. - - Args: - current_count: Current number of team members - - Returns: (can_add, error_message) - """ - if not self.is_active: - return False, "Subscription is not active" - - limit = self.team_members_limit - if limit is None: # Unlimited - return True, None - - if current_count >= limit: - return False, f"Team member limit reached ({limit} members). Upgrade to add more." - - return True, None - - def has_feature(self, feature: str) -> bool: - """Check if a feature is enabled for current tier.""" - return feature in self.features - - # ========================================================================= - # Usage Tracking - # ========================================================================= - - def increment_order_count(self) -> None: - """Increment the order counter for this period.""" - self.orders_this_period += 1 - - # Track when limit was first reached - limit = self.orders_limit - if limit and self.orders_this_period >= limit and not self.orders_limit_reached_at: - self.orders_limit_reached_at = datetime.now(UTC) - - def reset_period_counters(self) -> None: - """Reset counters for new billing period.""" - self.orders_this_period = 0 - self.orders_limit_reached_at = None - - -# ============================================================================ -# Capacity Planning -# ============================================================================ - - -class CapacitySnapshot(Base, TimestampMixin): - """ - Daily snapshot of platform capacity metrics. - - Used for growth trending and capacity forecasting. - Captured daily by background job. - """ - - __tablename__ = "capacity_snapshots" - - id = Column(Integer, primary_key=True, index=True) - snapshot_date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True) - - # Vendor metrics - total_vendors = Column(Integer, default=0, nullable=False) - active_vendors = Column(Integer, default=0, nullable=False) - trial_vendors = Column(Integer, default=0, nullable=False) - - # Subscription metrics - total_subscriptions = Column(Integer, default=0, nullable=False) - active_subscriptions = Column(Integer, default=0, nullable=False) - - # Resource metrics - total_products = Column(Integer, default=0, nullable=False) - total_orders_month = Column(Integer, default=0, nullable=False) - total_team_members = Column(Integer, default=0, nullable=False) - - # Storage metrics - storage_used_gb = Column(Numeric(10, 2), default=0, nullable=False) - db_size_mb = Column(Numeric(10, 2), default=0, nullable=False) - - # Capacity metrics (theoretical limits from subscriptions) - theoretical_products_limit = Column(Integer, nullable=True) - theoretical_orders_limit = Column(Integer, nullable=True) - theoretical_team_limit = Column(Integer, nullable=True) - - # Tier distribution (JSON: {"essential": 10, "professional": 5, ...}) - tier_distribution = Column(JSON, nullable=True) - - # Performance metrics - avg_response_ms = Column(Integer, nullable=True) - peak_cpu_percent = Column(Numeric(5, 2), nullable=True) - peak_memory_percent = Column(Numeric(5, 2), nullable=True) - - # Indexes - __table_args__ = ( - Index("ix_capacity_snapshots_date", "snapshot_date"), - ) - - def __repr__(self) -> str: - return f"" +__all__ = [ + # Enums + "TierCode", + "SubscriptionStatus", + "AddOnCategory", + "BillingPeriod", + # Models + "SubscriptionTier", + "AddOnProduct", + "VendorAddOn", + "StripeWebhookEvent", + "BillingHistory", + "VendorSubscription", + "CapacitySnapshot", + # Legacy constants + "TIER_LIMITS", +] diff --git a/models/database/test_run.py b/models/database/test_run.py index 24635e48..159bc18e 100644 --- a/models/database/test_run.py +++ b/models/database/test_run.py @@ -1,147 +1,23 @@ +# models/database/test_run.py """ -Test Run Models -Database models for tracking pytest test runs and results +Test Run Models - LEGACY LOCATION + +This file exists for backward compatibility. +The canonical location is now: app/modules/dev_tools/models/test_run.py + +All imports should use the new location: + from app.modules.dev_tools.models import TestRun, TestResult, TestCollection """ -from sqlalchemy import ( - JSON, - Column, - DateTime, - Float, - ForeignKey, - Integer, - String, - Text, +# Re-export from canonical location for backward compatibility +from app.modules.dev_tools.models.test_run import ( + TestRun, + TestResult, + TestCollection, ) -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -from app.core.database import Base - - -class TestRun(Base): - """Represents a single pytest run""" - - __tablename__ = "test_runs" - - id = Column(Integer, primary_key=True, index=True) - timestamp = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False, index=True - ) - - # Test counts - total_tests = Column(Integer, default=0) - passed = Column(Integer, default=0) - failed = Column(Integer, default=0) - errors = Column(Integer, default=0) - skipped = Column(Integer, default=0) - xfailed = Column(Integer, default=0) # Expected failures - xpassed = Column(Integer, default=0) # Unexpected passes - - # Coverage info (optional) - coverage_percent = Column(Float, nullable=True) - - # Timing - duration_seconds = Column(Float, default=0.0) - - # Run metadata - triggered_by = Column(String(100)) # 'manual', 'scheduled', 'ci/cd' - git_commit_hash = Column(String(40)) - git_branch = Column(String(100)) - test_path = Column(String(500)) # Which tests were run (e.g., 'tests/unit') - pytest_args = Column(String(500)) # Command line arguments used - - # Status - status = Column( - String(20), default="running", index=True - ) # 'running', 'passed', 'failed', 'error' - - # Celery task tracking (optional - for USE_CELERY=true) - celery_task_id = Column(String(255), nullable=True, index=True) - - # Relationship to test results - results = relationship( - "TestResult", back_populates="run", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - @property - def pass_rate(self) -> float: - """Calculate pass rate as percentage""" - if self.total_tests == 0: - return 0.0 - return (self.passed / self.total_tests) * 100 - - -class TestResult(Base): - """Represents a single test result from a pytest run""" - - __tablename__ = "test_results" - - id = Column(Integer, primary_key=True, index=True) - run_id = Column(Integer, ForeignKey("test_runs.id"), nullable=False, index=True) - - # Test identification - node_id = Column( - String(500), nullable=False, index=True - ) # e.g., 'tests/unit/test_foo.py::test_bar' - test_name = Column(String(200), nullable=False) # e.g., 'test_bar' - test_file = Column(String(300), nullable=False) # e.g., 'tests/unit/test_foo.py' - test_class = Column(String(200)) # e.g., 'TestFooClass' (optional) - - # Result - outcome = Column( - String(20), nullable=False, index=True - ) # 'passed', 'failed', 'error', 'skipped', 'xfailed', 'xpassed' - duration_seconds = Column(Float, default=0.0) - - # Failure details (if applicable) - error_message = Column(Text) - traceback = Column(Text) - - # Test metadata - markers = Column(JSON) # List of pytest markers - parameters = Column(JSON) # Parametrized test params - - # Timestamps - created_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - # Relationships - run = relationship("TestRun", back_populates="results") - - def __repr__(self): - return f"" - - -class TestCollection(Base): - """Cached test collection info for quick stats""" - - __tablename__ = "test_collections" - - id = Column(Integer, primary_key=True, index=True) - - # Collection stats - total_tests = Column(Integer, default=0) - total_files = Column(Integer, default=0) - total_classes = Column(Integer, default=0) - - # By category - unit_tests = Column(Integer, default=0) - integration_tests = Column(Integer, default=0) - performance_tests = Column(Integer, default=0) - system_tests = Column(Integer, default=0) - - # Collection data - test_files = Column(JSON) # List of test files with counts - - # Timestamps - collected_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - def __repr__(self): - return f"" +__all__ = [ + "TestRun", + "TestResult", + "TestCollection", +] diff --git a/models/schema/marketplace_import_job.py b/models/schema/marketplace_import_job.py index 7e81df1d..4187b16b 100644 --- a/models/schema/marketplace_import_job.py +++ b/models/schema/marketplace_import_job.py @@ -1,167 +1,39 @@ -from datetime import datetime +# models/schema/marketplace_import_job.py +""" +Legacy location for marketplace import job schemas. -from pydantic import BaseModel, ConfigDict, Field, field_validator +MIGRATED: All schemas have been moved to app.modules.marketplace.schemas.marketplace_import_job. - -class MarketplaceImportJobRequest(BaseModel): - """Request schema for triggering marketplace import. - - Note: vendor_id is injected by middleware, not from request body. - """ - - source_url: str = Field(..., description="URL to CSV file from marketplace") - marketplace: str = Field(default="Letzshop", description="Marketplace name") - batch_size: int | None = Field( - 1000, description="Processing batch size", ge=100, le=10000 - ) - language: str = Field( - default="en", - description="Language code for product translations (e.g., 'en', 'fr', 'de')", +New location: + from app.modules.marketplace.schemas import ( + MarketplaceImportJobRequest, + MarketplaceImportJobResponse, ) - @field_validator("source_url") - @classmethod - def validate_url(cls, v): - if not v.startswith(("http://", "https://")): # noqa: SEC-034 - raise ValueError("URL must start with http:// or https://") # noqa: SEC-034 - return v.strip() +This file re-exports from the new location for backward compatibility. +""" - @field_validator("marketplace") - @classmethod - def validate_marketplace(cls, v): - return v.strip() +# Re-export everything from the new canonical location +from app.modules.marketplace.schemas.marketplace_import_job import ( + MarketplaceImportJobRequest, + AdminMarketplaceImportJobRequest, + MarketplaceImportJobResponse, + MarketplaceImportJobListResponse, + MarketplaceImportErrorResponse, + MarketplaceImportErrorListResponse, + AdminMarketplaceImportJobResponse, + AdminMarketplaceImportJobListResponse, + MarketplaceImportJobStatusUpdate, +) - @field_validator("language") - @classmethod - def validate_language(cls, v): - # Basic language code validation (2-5 chars) - v = v.strip().lower() - if not 2 <= len(v) <= 5: - raise ValueError("Language code must be 2-5 characters (e.g., 'en', 'fr')") - return v - - -class AdminMarketplaceImportJobRequest(BaseModel): - """Request schema for admin-triggered marketplace import. - - Includes vendor_id since admin can import for any vendor. - """ - - vendor_id: int = Field(..., description="Vendor ID to import products for") - source_url: str = Field(..., description="URL to CSV file from marketplace") - marketplace: str = Field(default="Letzshop", description="Marketplace name") - batch_size: int | None = Field( - 1000, description="Processing batch size", ge=100, le=10000 - ) - language: str = Field( - default="en", - description="Language code for product translations (e.g., 'en', 'fr', 'de')", - ) - - @field_validator("source_url") - @classmethod - def validate_url(cls, v): - if not v.startswith(("http://", "https://")): # noqa: SEC-034 - raise ValueError("URL must start with http:// or https://") # noqa: SEC-034 - return v.strip() - - @field_validator("marketplace") - @classmethod - def validate_marketplace(cls, v): - return v.strip() - - @field_validator("language") - @classmethod - def validate_language(cls, v): - v = v.strip().lower() - if not 2 <= len(v) <= 5: - raise ValueError("Language code must be 2-5 characters (e.g., 'en', 'fr')") - return v - - -class MarketplaceImportErrorResponse(BaseModel): - """Response schema for individual import error.""" - - model_config = ConfigDict(from_attributes=True) - - id: int - row_number: int - identifier: str | None = None - error_type: str - error_message: str - row_data: dict | None = None - created_at: datetime - - -class MarketplaceImportErrorListResponse(BaseModel): - """Response schema for list of import errors.""" - - errors: list[MarketplaceImportErrorResponse] - total: int - import_job_id: int - - -class MarketplaceImportJobResponse(BaseModel): - """Response schema for marketplace import job.""" - - model_config = ConfigDict(from_attributes=True) - - job_id: int - vendor_id: int - vendor_code: str | None = None # Populated from vendor relationship - vendor_name: str | None = None # Populated from vendor relationship - marketplace: str - source_url: str - status: str - language: str | None = None # Language used for translations - - # Counts - imported: int = 0 - updated: int = 0 - total_processed: int = 0 - error_count: int = 0 - - # Error details - error_message: str | None = None - - # Timestamps - created_at: datetime - started_at: datetime | None = None - completed_at: datetime | None = None - - -class MarketplaceImportJobListResponse(BaseModel): - """Response schema for list of import jobs.""" - - jobs: list[MarketplaceImportJobResponse] - total: int - skip: int - limit: int - - -class AdminMarketplaceImportJobResponse(MarketplaceImportJobResponse): - """Extended response schema for admin with additional fields.""" - - id: int # Alias for job_id (frontend compatibility) - error_details: list = [] # Placeholder for future error details - created_by_name: str | None = None # Username of who created the job - - -class AdminMarketplaceImportJobListResponse(BaseModel): - """Response schema for paginated list of import jobs (admin).""" - - items: list[AdminMarketplaceImportJobResponse] - total: int - page: int - limit: int - - -class MarketplaceImportJobStatusUpdate(BaseModel): - """Schema for updating import job status (internal use).""" - - status: str - imported_count: int | None = None - updated_count: int | None = None - error_count: int | None = None - total_processed: int | None = None - error_message: str | None = None +__all__ = [ + "MarketplaceImportJobRequest", + "AdminMarketplaceImportJobRequest", + "MarketplaceImportJobResponse", + "MarketplaceImportJobListResponse", + "MarketplaceImportErrorResponse", + "MarketplaceImportErrorListResponse", + "AdminMarketplaceImportJobResponse", + "AdminMarketplaceImportJobListResponse", + "MarketplaceImportJobStatusUpdate", +] diff --git a/models/schema/marketplace_product.py b/models/schema/marketplace_product.py index 576e465e..df1b0c99 100644 --- a/models/schema/marketplace_product.py +++ b/models/schema/marketplace_product.py @@ -1,225 +1,45 @@ # models/schema/marketplace_product.py -"""Pydantic schemas for MarketplaceProduct API validation. +""" +Legacy location for marketplace product schemas. -Note: title and description are stored in MarketplaceProductTranslation table, -but we keep them in the API schemas for convenience. The service layer -handles creating/updating translations separately. +MIGRATED: All schemas have been moved to app.modules.marketplace.schemas.marketplace_product. + +New location: + from app.modules.marketplace.schemas import ( + MarketplaceProductCreate, + MarketplaceProductResponse, + MarketplaceProductTranslationSchema, + ) + +This file re-exports from the new location for backward compatibility. """ -from datetime import datetime +# Re-export everything from the new canonical location +from app.modules.marketplace.schemas.marketplace_product import ( + # Translation schemas + MarketplaceProductTranslationSchema, + # Base schemas + MarketplaceProductBase, + # CRUD schemas + MarketplaceProductCreate, + MarketplaceProductUpdate, + # Response schemas + MarketplaceProductResponse, + MarketplaceProductListResponse, + MarketplaceProductDetailResponse, + # Import schemas + MarketplaceImportRequest, + MarketplaceImportResponse, +) -from pydantic import BaseModel, ConfigDict, Field - -from models.schema.inventory import ProductInventorySummary - - -class MarketplaceProductTranslationSchema(BaseModel): - """Schema for product translation.""" - - model_config = ConfigDict(from_attributes=True) - - language: str - title: str - description: str | None = None - short_description: str | None = None - meta_title: str | None = None - meta_description: str | None = None - url_slug: str | None = None - - -class MarketplaceProductBase(BaseModel): - """Base schema for marketplace products.""" - - marketplace_product_id: str | None = None - - # Localized fields (passed to translations) - title: str | None = None - description: str | None = None - - # Links and media - link: str | None = None - image_link: str | None = None - additional_image_link: str | None = None - - # Status - availability: str | None = None - is_active: bool | None = None - - # Pricing - price: str | None = None - sale_price: str | None = None - currency: str | None = None - - # Product identifiers - brand: str | None = None - gtin: str | None = None - mpn: str | None = None - sku: str | None = None - - # Product attributes - condition: str | None = None - adult: str | None = None - multipack: int | None = None - is_bundle: str | None = None - age_group: str | None = None - color: str | None = None - gender: str | None = None - material: str | None = None - pattern: str | None = None - size: str | None = None - size_type: str | None = None - size_system: str | None = None - item_group_id: str | None = None - - # Categories - google_product_category: str | None = None - product_type_raw: str | None = ( - None # Original feed value (renamed from product_type) - ) - category_path: str | None = None - - # Custom labels - custom_label_0: str | None = None - custom_label_1: str | None = None - custom_label_2: str | None = None - custom_label_3: str | None = None - custom_label_4: str | None = None - - # Unit pricing - unit_pricing_measure: str | None = None - unit_pricing_base_measure: str | None = None - identifier_exists: str | None = None - shipping: str | None = None - - # Source tracking - marketplace: str | None = None - vendor_name: str | None = None - source_url: str | None = None - - # Product type classification - product_type_enum: str | None = ( - None # 'physical', 'digital', 'service', 'subscription' - ) - is_digital: bool | None = None - - # Digital product fields - digital_delivery_method: str | None = None - platform: str | None = None - license_type: str | None = None - - # Physical product fields - weight: float | None = None - weight_unit: str | None = None - - -class MarketplaceProductCreate(MarketplaceProductBase): - """Schema for creating a marketplace product.""" - - marketplace_product_id: str = Field( - ..., description="Unique product identifier from marketplace" - ) - # Title is required for API creation (will be stored in translations) - title: str = Field(..., description="Product title") - - -class MarketplaceProductUpdate(MarketplaceProductBase): - """Schema for updating a marketplace product. - - All fields are optional - only provided fields will be updated. - """ - - -class MarketplaceProductResponse(BaseModel): - """Schema for marketplace product API response.""" - - model_config = ConfigDict(from_attributes=True) - - id: int - marketplace_product_id: str - - # These will be populated from translations - title: str | None = None - description: str | None = None - - # Links and media - link: str | None = None - image_link: str | None = None - additional_image_link: str | None = None - - # Status - availability: str | None = None - is_active: bool | None = None - - # Pricing - price: str | None = None - price_numeric: float | None = None - sale_price: str | None = None - sale_price_numeric: float | None = None - currency: str | None = None - - # Product identifiers - brand: str | None = None - gtin: str | None = None - mpn: str | None = None - sku: str | None = None - - # Product attributes - condition: str | None = None - color: str | None = None - size: str | None = None - - # Categories - google_product_category: str | None = None - product_type_raw: str | None = None - category_path: str | None = None - - # Source tracking - marketplace: str | None = None - vendor_name: str | None = None - - # Product type - product_type_enum: str | None = None - is_digital: bool | None = None - platform: str | None = None - - # Timestamps - created_at: datetime - updated_at: datetime - - # Translations (optional - included when requested) - translations: list[MarketplaceProductTranslationSchema] | None = None - - -class MarketplaceProductListResponse(BaseModel): - """Schema for paginated product list response.""" - - products: list[MarketplaceProductResponse] - total: int - skip: int - limit: int - - -class MarketplaceProductDetailResponse(BaseModel): - """Schema for detailed product response with inventory.""" - - product: MarketplaceProductResponse - inventory_info: ProductInventorySummary | None = None - translations: list[MarketplaceProductTranslationSchema] | None = None - - -class MarketplaceImportRequest(BaseModel): - """Schema for marketplace import request.""" - - url: str = Field(..., description="URL to CSV file") - marketplace: str = Field(default="Letzshop", description="Marketplace name") - vendor_name: str | None = Field(default=None, description="Vendor name") - language: str = Field(default="en", description="Language code for translations") - batch_size: int = Field(default=100, ge=1, le=1000, description="Batch size") - - -class MarketplaceImportResponse(BaseModel): - """Schema for marketplace import response.""" - - job_id: int - status: str - message: str +__all__ = [ + "MarketplaceProductTranslationSchema", + "MarketplaceProductBase", + "MarketplaceProductCreate", + "MarketplaceProductUpdate", + "MarketplaceProductResponse", + "MarketplaceProductListResponse", + "MarketplaceProductDetailResponse", + "MarketplaceImportRequest", + "MarketplaceImportResponse", +] diff --git a/models/schema/stats.py b/models/schema/stats.py index 8f7e6e5a..75f86877 100644 --- a/models/schema/stats.py +++ b/models/schema/stats.py @@ -1,319 +1,63 @@ -from datetime import datetime -from decimal import Decimal -from typing import Any - -from pydantic import BaseModel, Field - - -class StatsResponse(BaseModel): - """Comprehensive platform statistics response schema.""" - - total_products: int - unique_brands: int - unique_categories: int - unique_marketplaces: int = 0 - unique_vendors: int = 0 - total_inventory_entries: int = 0 - total_inventory_quantity: int = 0 - - -class MarketplaceStatsResponse(BaseModel): - """Statistics per marketplace response schema.""" - - marketplace: str - total_products: int - unique_vendors: int - unique_brands: int - - -# ============================================================================ -# Import Statistics -# ============================================================================ - - -class ImportStatsResponse(BaseModel): - """Import job statistics response schema. - - Used by: GET /api/v1/admin/marketplace-import-jobs/stats - """ - - total: int = Field(..., description="Total number of import jobs") - pending: int = Field(..., description="Jobs waiting to start") - processing: int = Field(..., description="Jobs currently running") - completed: int = Field(..., description="Successfully completed jobs") - failed: int = Field(..., description="Failed jobs") - success_rate: float = Field(..., description="Percentage of successful imports") - - -# ============================================================================ -# User Statistics -# ============================================================================ - - -class UserStatsResponse(BaseModel): - """User statistics response schema. - - Used by: Platform statistics endpoints - """ - - total_users: int = Field(..., description="Total number of users") - active_users: int = Field(..., description="Number of active users") - inactive_users: int = Field(..., description="Number of inactive users") - admin_users: int = Field(..., description="Number of admin users") - activation_rate: float = Field(..., description="Percentage of active users") - - -# ============================================================================ -# Vendor Statistics (Admin) -# ============================================================================ - - -class VendorStatsResponse(BaseModel): - """Vendor statistics response schema for admin dashboard. - - Used by: GET /api/v1/admin/vendors/stats - """ - - total: int = Field(..., description="Total number of vendors") - verified: int = Field(..., description="Number of verified vendors") - pending: int = Field(..., description="Number of pending verification vendors") - inactive: int = Field(..., description="Number of inactive vendors") - - -# ============================================================================ -# Product Statistics -# ============================================================================ - - -class ProductStatsResponse(BaseModel): - """Product statistics response schema. - - Used by: Platform statistics endpoints - """ - - total_products: int = Field(0, description="Total number of products") - active_products: int = Field(0, description="Number of active products") - out_of_stock: int = Field(0, description="Number of out-of-stock products") - - -# ============================================================================ -# Platform Statistics (Combined) -# ============================================================================ - - -class PlatformStatsResponse(BaseModel): - """Combined platform statistics response schema. - - Used by: GET /api/v1/admin/dashboard/stats/platform - """ - - users: UserStatsResponse - vendors: VendorStatsResponse - products: ProductStatsResponse - orders: "OrderStatsBasicResponse" - imports: ImportStatsResponse - - -class OrderStatsBasicResponse(BaseModel): - """Basic order statistics (stub until Order model is fully implemented). - - Used by: Platform statistics endpoints - """ - - total_orders: int = Field(0, description="Total number of orders") - pending_orders: int = Field(0, description="Number of pending orders") - completed_orders: int = Field(0, description="Number of completed orders") - - -# ============================================================================ -# Admin Dashboard Response -# ============================================================================ - - -class AdminDashboardResponse(BaseModel): - """Admin dashboard response schema. - - Used by: GET /api/v1/admin/dashboard - """ - - platform: dict[str, Any] = Field(..., description="Platform information") - users: UserStatsResponse - vendors: VendorStatsResponse - recent_vendors: list[dict[str, Any]] = Field( - default_factory=list, description="Recent vendors" - ) - recent_imports: list[dict[str, Any]] = Field( - default_factory=list, description="Recent import jobs" - ) - - -# ============================================================================ -# Vendor Dashboard Statistics -# ============================================================================ - - -class VendorProductStats(BaseModel): - """Vendor product statistics.""" - - total: int = Field(0, description="Total products in catalog") - active: int = Field(0, description="Active products") - - -class VendorOrderStats(BaseModel): - """Vendor order statistics.""" - - total: int = Field(0, description="Total orders") - pending: int = Field(0, description="Pending orders") - completed: int = Field(0, description="Completed orders") - - -class VendorCustomerStats(BaseModel): - """Vendor customer statistics.""" - - total: int = Field(0, description="Total customers") - active: int = Field(0, description="Active customers") - - -class VendorRevenueStats(BaseModel): - """Vendor revenue statistics.""" - - total: float = Field(0, description="Total revenue") - this_month: float = Field(0, description="Revenue this month") - - -class VendorInfo(BaseModel): - """Vendor basic info for dashboard.""" - - id: int - name: str - vendor_code: str - - -class VendorDashboardStatsResponse(BaseModel): - """Vendor dashboard statistics response schema. - - Used by: GET /api/v1/vendor/dashboard/stats - """ - - vendor: VendorInfo - products: VendorProductStats - orders: VendorOrderStats - customers: VendorCustomerStats - revenue: VendorRevenueStats - - -# ============================================================================ -# Vendor Analytics -# ============================================================================ - - -class VendorAnalyticsImports(BaseModel): - """Vendor import analytics.""" - - count: int = Field(0, description="Number of imports in period") - - -class VendorAnalyticsCatalog(BaseModel): - """Vendor catalog analytics.""" - - products_added: int = Field(0, description="Products added in period") - - -class VendorAnalyticsInventory(BaseModel): - """Vendor inventory analytics.""" - - total_locations: int = Field(0, description="Total inventory locations") - - -class VendorAnalyticsResponse(BaseModel): - """Vendor analytics response schema. - - Used by: GET /api/v1/vendor/analytics - """ - - period: str = Field(..., description="Analytics period (e.g., '30d')") - start_date: str = Field(..., description="Period start date") - imports: VendorAnalyticsImports - catalog: VendorAnalyticsCatalog - inventory: VendorAnalyticsInventory - - -# ============================================================================ -# Code Quality Dashboard Statistics -# ============================================================================ - - -class ValidatorStats(BaseModel): - """Statistics for a single validator type.""" - - total_violations: int = 0 - errors: int = 0 - warnings: int = 0 - last_scan: str | None = None - - -class CodeQualityDashboardStatsResponse(BaseModel): - """Code quality dashboard statistics response schema. - - Used by: GET /api/v1/admin/code-quality/stats - - Supports multiple validator types: architecture, security, performance. - When validator_type is specified, returns stats for that type only. - When not specified, returns combined stats with per-validator breakdown. - """ - - total_violations: int - errors: int - warnings: int - info: int = 0 - open: int - assigned: int - resolved: int - ignored: int - technical_debt_score: int - trend: list[dict[str, Any]] = Field(default_factory=list) - by_severity: dict[str, Any] = Field(default_factory=dict) - by_rule: dict[str, Any] = Field(default_factory=dict) - by_module: dict[str, Any] = Field(default_factory=dict) - top_files: list[dict[str, Any]] = Field(default_factory=list) - last_scan: str | None = None - validator_type: str | None = None # Set when filtering by type - by_validator: dict[str, ValidatorStats] = Field( - default_factory=dict, - description="Per-validator breakdown (architecture, security, performance)", - ) - - -# ============================================================================ -# Customer Statistics (Coming Soon) -# ============================================================================ - - -class CustomerStatsResponse(BaseModel): - """Schema for customer statistics.""" - - customer_id: int - total_orders: int - total_spent: Decimal - average_order_value: Decimal - last_order_date: datetime | None - first_order_date: datetime | None - lifetime_value: Decimal - - -# ============================================================================ -# Order Statistics (Coming Soon) -# ============================================================================ - - -class OrderStatsResponse(BaseModel): - """Schema for order statistics.""" - - total_orders: int - pending_orders: int - processing_orders: int - shipped_orders: int - delivered_orders: int - cancelled_orders: int - total_revenue: Decimal - average_order_value: Decimal +# models/schema/stats.py +""" +Statistics schemas - LEGACY LOCATION + +This file exists for backward compatibility. +The canonical location is now: app/modules/analytics/schemas/stats.py + +All imports should use the new location: + from app.modules.analytics.schemas import StatsResponse, ... +""" + +# Re-export from canonical location for backward compatibility +from app.modules.analytics.schemas.stats import ( + StatsResponse, + MarketplaceStatsResponse, + ImportStatsResponse, + UserStatsResponse, + VendorStatsResponse, + ProductStatsResponse, + PlatformStatsResponse, + OrderStatsBasicResponse, + AdminDashboardResponse, + VendorProductStats, + VendorOrderStats, + VendorCustomerStats, + VendorRevenueStats, + VendorInfo, + VendorDashboardStatsResponse, + VendorAnalyticsImports, + VendorAnalyticsCatalog, + VendorAnalyticsInventory, + VendorAnalyticsResponse, + ValidatorStats, + CodeQualityDashboardStatsResponse, + CustomerStatsResponse, + OrderStatsResponse, +) + +__all__ = [ + "StatsResponse", + "MarketplaceStatsResponse", + "ImportStatsResponse", + "UserStatsResponse", + "VendorStatsResponse", + "ProductStatsResponse", + "PlatformStatsResponse", + "OrderStatsBasicResponse", + "AdminDashboardResponse", + "VendorProductStats", + "VendorOrderStats", + "VendorCustomerStats", + "VendorRevenueStats", + "VendorInfo", + "VendorDashboardStatsResponse", + "VendorAnalyticsImports", + "VendorAnalyticsCatalog", + "VendorAnalyticsInventory", + "VendorAnalyticsResponse", + "ValidatorStats", + "CodeQualityDashboardStatsResponse", + "CustomerStatsResponse", + "OrderStatsResponse", +] diff --git a/models/schema/subscription.py b/models/schema/subscription.py index f5d8f85c..baeeeed2 100644 --- a/models/schema/subscription.py +++ b/models/schema/subscription.py @@ -1,209 +1,58 @@ # models/schema/subscription.py """ -Pydantic schemas for subscription operations. +Legacy location for subscription schemas. -Supports subscription management and tier limit checks. +MIGRATED: All schemas have been moved to app.modules.billing.schemas.subscription. + +New location: + from app.modules.billing.schemas import ( + SubscriptionCreate, + SubscriptionResponse, + TierInfo, + ) + +This file re-exports from the new location for backward compatibility. """ -from datetime import datetime +# Re-export everything from the new canonical location +from app.modules.billing.schemas.subscription import ( + # Tier schemas + TierFeatures, + TierLimits, + TierInfo, + # Subscription CRUD schemas + SubscriptionCreate, + SubscriptionUpdate, + SubscriptionResponse, + # Usage schemas + SubscriptionUsage, + UsageSummary, + SubscriptionStatusResponse, + # Limit check schemas + LimitCheckResult, + CanCreateOrderResponse, + CanAddProductResponse, + CanAddTeamMemberResponse, + FeatureCheckResponse, +) -from pydantic import BaseModel, ConfigDict, Field - - -# ============================================================================ -# Tier Information Schemas -# ============================================================================ - - -class TierFeatures(BaseModel): - """Features included in a tier.""" - - letzshop_sync: bool = True - inventory_basic: bool = True - inventory_locations: bool = False - inventory_purchase_orders: bool = False - invoice_lu: bool = True - invoice_eu_vat: bool = False - invoice_bulk: bool = False - customer_view: bool = True - customer_export: bool = False - analytics_dashboard: bool = False - accounting_export: bool = False - api_access: bool = False - automation_rules: bool = False - team_roles: bool = False - white_label: bool = False - multi_vendor: bool = False - custom_integrations: bool = False - sla_guarantee: bool = False - dedicated_support: bool = False - - -class TierLimits(BaseModel): - """Limits for a subscription tier.""" - - orders_per_month: int | None = Field(None, description="None = unlimited") - products_limit: int | None = Field(None, description="None = unlimited") - team_members: int | None = Field(None, description="None = unlimited") - order_history_months: int | None = Field(None, description="None = unlimited") - - -class TierInfo(BaseModel): - """Full tier information.""" - - code: str - name: str - price_monthly_cents: int - price_annual_cents: int | None - limits: TierLimits - features: list[str] - - -# ============================================================================ -# Subscription Schemas -# ============================================================================ - - -class SubscriptionCreate(BaseModel): - """Schema for creating a subscription (admin/internal use).""" - - tier: str = Field(default="essential", pattern="^(essential|professional|business|enterprise)$") - is_annual: bool = False - trial_days: int = Field(default=14, ge=0, le=30) - - -class SubscriptionUpdate(BaseModel): - """Schema for updating a subscription.""" - - tier: str | None = Field(None, pattern="^(essential|professional|business|enterprise)$") - status: str | None = Field(None, pattern="^(trial|active|past_due|cancelled|expired)$") - is_annual: bool | None = None - custom_orders_limit: int | None = None - custom_products_limit: int | None = None - custom_team_limit: int | None = None - - -class SubscriptionResponse(BaseModel): - """Schema for subscription response.""" - - model_config = ConfigDict(from_attributes=True) - - id: int - vendor_id: int - tier: str - status: str - - period_start: datetime - period_end: datetime - is_annual: bool - - trial_ends_at: datetime | None - orders_this_period: int - orders_limit_reached_at: datetime | None - - # Effective limits (with custom overrides applied) - orders_limit: int | None - products_limit: int | None - team_members_limit: int | None - - # Computed properties - is_active: bool - is_trial: bool - trial_days_remaining: int | None - - created_at: datetime - updated_at: datetime - - -class SubscriptionUsage(BaseModel): - """Current subscription usage statistics.""" - - orders_used: int - orders_limit: int | None - orders_remaining: int | None - orders_percent_used: float | None - - products_used: int - products_limit: int | None - products_remaining: int | None - products_percent_used: float | None - - team_members_used: int - team_members_limit: int | None - team_members_remaining: int | None - team_members_percent_used: float | None - - -class UsageSummary(BaseModel): - """Usage summary for billing page display.""" - - orders_this_period: int - orders_limit: int | None - orders_remaining: int | None - - products_count: int - products_limit: int | None - products_remaining: int | None - - team_count: int - team_limit: int | None - team_remaining: int | None - - -class SubscriptionStatusResponse(BaseModel): - """Subscription status with usage and limits.""" - - subscription: SubscriptionResponse - usage: SubscriptionUsage - tier_info: TierInfo - - -# ============================================================================ -# Limit Check Schemas -# ============================================================================ - - -class LimitCheckResult(BaseModel): - """Result of a limit check.""" - - allowed: bool - limit: int | None - current: int - remaining: int | None - message: str | None = None - - -class CanCreateOrderResponse(BaseModel): - """Response for order creation check.""" - - allowed: bool - orders_this_period: int - orders_limit: int | None - message: str | None = None - - -class CanAddProductResponse(BaseModel): - """Response for product addition check.""" - - allowed: bool - products_count: int - products_limit: int | None - message: str | None = None - - -class CanAddTeamMemberResponse(BaseModel): - """Response for team member addition check.""" - - allowed: bool - team_count: int - team_limit: int | None - message: str | None = None - - -class FeatureCheckResponse(BaseModel): - """Response for feature check.""" - - feature: str - enabled: bool - tier_required: str | None = None - message: str | None = None +__all__ = [ + # Tier schemas + "TierFeatures", + "TierLimits", + "TierInfo", + # Subscription CRUD schemas + "SubscriptionCreate", + "SubscriptionUpdate", + "SubscriptionResponse", + # Usage schemas + "SubscriptionUsage", + "UsageSummary", + "SubscriptionStatusResponse", + # Limit check schemas + "LimitCheckResult", + "CanCreateOrderResponse", + "CanAddProductResponse", + "CanAddTeamMemberResponse", + "FeatureCheckResponse", +]