refactor: migrate modules from re-exports to canonical implementations

Move actual code implementations into module directories:
- orders: 5 services, 4 models, order/invoice schemas
- inventory: 3 services, 2 models, 30+ schemas
- customers: 3 services, 2 models, customer schemas
- messaging: 3 services, 2 models, message/notification schemas
- monitoring: background_tasks_service
- marketplace: 5+ services including letzshop submodule
- dev_tools: code_quality_service, test_runner_service
- billing: billing_service
- contracts: definition.py

Legacy files in app/services/, models/database/, models/schema/
now re-export from canonical module locations for backwards
compatibility. Architecture validator passes with 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

@@ -1,82 +1,23 @@
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
# models/database/customer.py
"""
LEGACY LOCATION - Re-exports from module for backwards compatibility.
The canonical implementation is now in:
app/modules/customers/models/customer.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.customers.models import Customer, CustomerAddress
"""
from app.modules.customers.models.customer import (
Customer,
CustomerAddress,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from .base import TimestampMixin
class Customer(Base, TimestampMixin):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
email = Column(
String(255), nullable=False, index=True
) # Unique within vendor scope
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
customer_number = Column(
String(100), nullable=False, index=True
) # Vendor-specific ID
preferences = Column(JSON, default=dict)
marketing_consent = Column(Boolean, default=False)
last_order_date = Column(DateTime)
total_orders = Column(Integer, default=0)
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Language preference (NULL = use vendor storefront_language default)
# Supported: en, fr, de, lb
preferred_language = Column(String(5), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="customers")
addresses = relationship("CustomerAddress", back_populates="customer")
orders = relationship("Order", back_populates="customer")
def __repr__(self):
return f"<Customer(id={self.id}, vendor_id={self.vendor_id}, email='{self.email}')>"
@property
def full_name(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerAddress(Base, TimestampMixin):
__tablename__ = "customer_addresses"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
address_type = Column(String(50), nullable=False) # 'billing', 'shipping'
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
company = Column(String(200))
address_line_1 = Column(String(255), nullable=False)
address_line_2 = Column(String(255))
city = Column(String(100), nullable=False)
postal_code = Column(String(20), nullable=False)
country_name = Column(String(100), nullable=False)
country_iso = Column(String(5), nullable=False)
is_default = Column(Boolean, default=False)
# Relationships
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="addresses")
def __repr__(self):
return f"<CustomerAddress(id={self.id}, customer_id={self.customer_id}, type='{self.address_type}')>"
__all__ = [
"Customer",
"CustomerAddress",
]

View File

@@ -1,61 +1,19 @@
# models/database/inventory.py
"""
Inventory model for tracking stock at warehouse/bin locations.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Each entry represents a quantity of a product at a specific bin location
within a warehouse. Products can be scattered across multiple bins.
The canonical implementation is now in:
app/modules/inventory/models/inventory.py
Example:
Warehouse: "strassen"
Bin: "SA-10-02"
Product: GTIN 4007817144145
Quantity: 3
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.inventory.models import Inventory
"""
from sqlalchemy import Column, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import relationship
from app.modules.inventory.models.inventory import Inventory
from app.core.database import Base
from models.database.base import TimestampMixin
class Inventory(Base, TimestampMixin):
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
# Location: warehouse + bin
warehouse = Column(String, nullable=False, default="strassen", index=True)
bin_location = Column(String, nullable=False, index=True) # e.g., "SA-10-02"
# Legacy field - kept for backward compatibility, will be removed
location = Column(String, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0)
# Keep GTIN for reference/reporting (matches Product.gtin)
gtin = Column(String, index=True)
# Relationships
product = relationship("Product", back_populates="inventory_entries")
vendor = relationship("Vendor")
# Constraints
__table_args__ = (
UniqueConstraint(
"product_id", "warehouse", "bin_location", name="uq_inventory_product_warehouse_bin"
),
Index("idx_inventory_vendor_product", "vendor_id", "product_id"),
Index("idx_inventory_warehouse_bin", "warehouse", "bin_location"),
)
def __repr__(self):
return f"<Inventory(product_id={self.product_id}, location='{self.location}', quantity={self.quantity})>"
@property
def available_quantity(self):
"""Calculate available quantity (total - reserved)."""
return max(0, self.quantity - self.reserved_quantity)
__all__ = [
"Inventory",
]

View File

@@ -1,170 +1,23 @@
# models/database/inventory_transaction.py
"""
Inventory Transaction Model - Audit trail for all stock movements.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
This model tracks every change to inventory quantities, providing:
- Complete audit trail for compliance and debugging
- Order-linked transactions for traceability
- Support for different transaction types (reserve, fulfill, adjust, etc.)
The canonical implementation is now in:
app/modules/inventory/models/inventory_transaction.py
All stock movements should create a transaction record.
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.inventory.models import InventoryTransaction, TransactionType
"""
from datetime import UTC, datetime
from enum import Enum
from sqlalchemy import (
Column,
DateTime,
Enum as SQLEnum,
ForeignKey,
Index,
Integer,
String,
Text,
from app.modules.inventory.models.inventory_transaction import (
InventoryTransaction,
TransactionType,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
class TransactionType(str, Enum):
"""Types of inventory transactions."""
# Order-related
RESERVE = "reserve" # Stock reserved for order
FULFILL = "fulfill" # Reserved stock consumed (shipped)
RELEASE = "release" # Reserved stock released (cancelled)
# Manual adjustments
ADJUST = "adjust" # Manual adjustment (+/-)
SET = "set" # Set to exact quantity
# Imports
IMPORT = "import" # Initial import/sync
# Returns
RETURN = "return" # Stock returned from customer
class InventoryTransaction(Base):
"""
Audit log for inventory movements.
Every change to inventory quantity creates a transaction record,
enabling complete traceability of stock levels over time.
"""
__tablename__ = "inventory_transactions"
id = Column(Integer, primary_key=True, index=True)
# Core references
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
inventory_id = Column(
Integer, ForeignKey("inventory.id"), nullable=True, index=True
)
# Transaction details
transaction_type = Column(
SQLEnum(TransactionType), nullable=False, index=True
)
quantity_change = Column(Integer, nullable=False) # Positive = add, negative = remove
# Quantities after transaction (snapshot)
quantity_after = Column(Integer, nullable=False)
reserved_after = Column(Integer, nullable=False, default=0)
# Location context
location = Column(String, nullable=True)
warehouse = Column(String, nullable=True)
# Order reference (for order-related transactions)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True, index=True)
order_number = Column(String, nullable=True)
# Audit fields
reason = Column(Text, nullable=True) # Human-readable reason
created_by = Column(String, nullable=True) # User/system that created
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
index=True,
)
# Relationships
vendor = relationship("Vendor")
product = relationship("Product")
inventory = relationship("Inventory")
order = relationship("Order")
# Indexes for common queries
__table_args__ = (
Index("idx_inv_tx_vendor_product", "vendor_id", "product_id"),
Index("idx_inv_tx_vendor_created", "vendor_id", "created_at"),
Index("idx_inv_tx_order", "order_id"),
Index("idx_inv_tx_type_created", "transaction_type", "created_at"),
)
def __repr__(self) -> str:
return (
f"<InventoryTransaction {self.id}: "
f"{self.transaction_type.value} {self.quantity_change:+d} "
f"for product {self.product_id}>"
)
@classmethod
def create_transaction(
cls,
vendor_id: int,
product_id: int,
transaction_type: TransactionType,
quantity_change: int,
quantity_after: int,
reserved_after: int = 0,
inventory_id: int | None = None,
location: str | None = None,
warehouse: str | None = None,
order_id: int | None = None,
order_number: str | None = None,
reason: str | None = None,
created_by: str | None = None,
) -> "InventoryTransaction":
"""
Factory method to create a transaction record.
Args:
vendor_id: Vendor ID
product_id: Product ID
transaction_type: Type of transaction
quantity_change: Change in quantity (positive = add, negative = remove)
quantity_after: Total quantity after this transaction
reserved_after: Reserved quantity after this transaction
inventory_id: Optional inventory record ID
location: Optional location
warehouse: Optional warehouse
order_id: Optional order ID (for order-related transactions)
order_number: Optional order number for display
reason: Optional human-readable reason
created_by: Optional user/system identifier
Returns:
InventoryTransaction instance (not yet added to session)
"""
return cls(
vendor_id=vendor_id,
product_id=product_id,
inventory_id=inventory_id,
transaction_type=transaction_type,
quantity_change=quantity_change,
quantity_after=quantity_after,
reserved_after=reserved_after,
location=location,
warehouse=warehouse,
order_id=order_id,
order_number=order_number,
reason=reason,
created_by=created_by,
)
__all__ = [
"InventoryTransaction",
"TransactionType",
]

View File

@@ -1,215 +1,27 @@
# models/database/invoice.py
"""
Invoice database models for the OMS.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Provides models for:
- VendorInvoiceSettings: Per-vendor invoice configuration (company details, VAT, numbering)
- Invoice: Invoice records with snapshots of seller/buyer details
The canonical implementation is now in:
app/modules/orders/models/invoice.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.orders.models import Invoice, InvoiceStatus, VATRegime, VendorInvoiceSettings
"""
import enum
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
from app.modules.orders.models.invoice import (
Invoice,
InvoiceStatus,
VATRegime,
VendorInvoiceSettings,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class VendorInvoiceSettings(Base, TimestampMixin):
"""
Per-vendor invoice configuration.
Stores company details, VAT number, invoice numbering preferences,
and payment information for invoice generation.
One-to-one relationship with Vendor.
"""
__tablename__ = "vendor_invoice_settings"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Legal company details for invoice header
company_name = Column(String(255), nullable=False) # Legal name for invoices
company_address = Column(String(255), nullable=True) # Street address
company_city = Column(String(100), nullable=True)
company_postal_code = Column(String(20), nullable=True)
company_country = Column(String(2), nullable=False, default="LU") # ISO country code
# VAT information
vat_number = Column(String(50), nullable=True) # e.g., "LU12345678"
is_vat_registered = Column(Boolean, default=True, nullable=False)
# OSS (One-Stop-Shop) for EU VAT
is_oss_registered = Column(Boolean, default=False, nullable=False)
oss_registration_country = Column(String(2), nullable=True) # ISO country code
# Invoice numbering
invoice_prefix = Column(String(20), default="INV", nullable=False)
invoice_next_number = Column(Integer, default=1, nullable=False)
invoice_number_padding = Column(Integer, default=5, nullable=False) # e.g., INV00001
# Payment information
payment_terms = Column(Text, nullable=True) # e.g., "Payment due within 30 days"
bank_name = Column(String(255), nullable=True)
bank_iban = Column(String(50), nullable=True)
bank_bic = Column(String(20), nullable=True)
# Invoice footer
footer_text = Column(Text, nullable=True) # Custom footer text
# Default VAT rate for Luxembourg invoices (17% standard)
default_vat_rate = Column(Numeric(5, 2), default=17.00, nullable=False)
# Relationships
vendor = relationship("Vendor", back_populates="invoice_settings")
def __repr__(self):
return f"<VendorInvoiceSettings(vendor_id={self.vendor_id}, company='{self.company_name}')>"
def get_next_invoice_number(self) -> str:
"""Generate the next invoice number and increment counter."""
number = str(self.invoice_next_number).zfill(self.invoice_number_padding)
return f"{self.invoice_prefix}{number}"
class InvoiceStatus(str, enum.Enum):
"""Invoice status enumeration."""
DRAFT = "draft"
ISSUED = "issued"
PAID = "paid"
CANCELLED = "cancelled"
class VATRegime(str, enum.Enum):
"""VAT regime for invoice calculation."""
DOMESTIC = "domestic" # Same country as seller
OSS = "oss" # EU cross-border with OSS registration
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
EXEMPT = "exempt" # VAT exempt
class Invoice(Base, TimestampMixin):
"""
Invoice record with snapshots of seller/buyer details.
Stores complete invoice data including snapshots of seller and buyer
details at time of creation for audit purposes.
"""
__tablename__ = "invoices"
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=True, index=True)
# Invoice identification
invoice_number = Column(String(50), nullable=False)
invoice_date = Column(DateTime(timezone=True), nullable=False)
# Status
status = Column(String(20), default=InvoiceStatus.DRAFT.value, nullable=False)
# Seller details snapshot (captured at invoice creation)
seller_details = Column(JSON, nullable=False)
# Structure: {
# "company_name": str,
# "address": str,
# "city": str,
# "postal_code": str,
# "country": str,
# "vat_number": str | None
# }
# Buyer details snapshot (captured at invoice creation)
buyer_details = Column(JSON, nullable=False)
# Structure: {
# "name": str,
# "email": str,
# "address": str,
# "city": str,
# "postal_code": str,
# "country": str,
# "vat_number": str | None (for B2B)
# }
# Line items snapshot
line_items = Column(JSON, nullable=False)
# Structure: [{
# "description": str,
# "quantity": int,
# "unit_price_cents": int,
# "total_cents": int,
# "sku": str | None,
# "ean": str | None
# }]
# VAT information
vat_regime = Column(String(20), default=VATRegime.DOMESTIC.value, nullable=False)
destination_country = Column(String(2), nullable=True) # For OSS invoices
vat_rate = Column(Numeric(5, 2), nullable=False) # e.g., 17.00 for 17%
vat_rate_label = Column(String(50), nullable=True) # e.g., "Luxembourg Standard VAT"
# Amounts (stored in cents for precision)
currency = Column(String(3), default="EUR", nullable=False)
subtotal_cents = Column(Integer, nullable=False) # Before VAT
vat_amount_cents = Column(Integer, nullable=False) # VAT amount
total_cents = Column(Integer, nullable=False) # After VAT
# Payment information
payment_terms = Column(Text, nullable=True)
bank_details = Column(JSON, nullable=True) # IBAN, BIC snapshot
footer_text = Column(Text, nullable=True)
# PDF storage
pdf_generated_at = Column(DateTime(timezone=True), nullable=True)
pdf_path = Column(String(500), nullable=True) # Path to stored PDF
# Notes
notes = Column(Text, nullable=True) # Internal notes
# Relationships
vendor = relationship("Vendor", back_populates="invoices")
order = relationship("Order", back_populates="invoices")
__table_args__ = (
Index("idx_invoice_vendor_number", "vendor_id", "invoice_number", unique=True),
Index("idx_invoice_vendor_date", "vendor_id", "invoice_date"),
Index("idx_invoice_status", "vendor_id", "status"),
)
def __repr__(self):
return f"<Invoice(id={self.id}, number='{self.invoice_number}', status='{self.status}')>"
@property
def subtotal(self) -> float:
"""Get subtotal in EUR."""
return self.subtotal_cents / 100
@property
def vat_amount(self) -> float:
"""Get VAT amount in EUR."""
return self.vat_amount_cents / 100
@property
def total(self) -> float:
"""Get total in EUR."""
return self.total_cents / 100
__all__ = [
"Invoice",
"InvoiceStatus",
"VATRegime",
"VendorInvoiceSettings",
]

View File

@@ -1,272 +1,31 @@
# models/database/message.py
"""
Messaging system database models.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports three communication channels:
- Admin <-> Vendor
- Vendor <-> Customer
- Admin <-> Customer
The canonical implementation is now in:
app/modules/messaging/models/message.py
Multi-tenant isolation is enforced via vendor_id for conversations
involving customers.
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.messaging.models import message
"""
import enum
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
from app.modules.messaging.models.message import (
Conversation,
ConversationParticipant,
ConversationType,
Message,
MessageAttachment,
ParticipantType,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ConversationType(str, enum.Enum):
"""Defines the three supported conversation channels."""
ADMIN_VENDOR = "admin_vendor"
VENDOR_CUSTOMER = "vendor_customer"
ADMIN_CUSTOMER = "admin_customer"
class ParticipantType(str, enum.Enum):
"""Type of participant in a conversation."""
ADMIN = "admin" # User with role="admin"
VENDOR = "vendor" # User with role="vendor" (via VendorUser)
CUSTOMER = "customer" # Customer model
def _enum_values(enum_class):
"""Extract enum values for SQLAlchemy Enum column."""
return [e.value for e in enum_class]
class Conversation(Base, TimestampMixin):
"""
Represents a threaded conversation between participants.
Multi-tenancy: vendor_id is required for vendor_customer and admin_customer
conversations to ensure customer data isolation.
"""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
# Conversation type determines participant structure
conversation_type = Column(
Enum(ConversationType, values_callable=_enum_values),
nullable=False,
index=True,
)
# Subject line for the conversation thread
subject = Column(String(500), nullable=False)
# For vendor_customer and admin_customer conversations
# Required for multi-tenant data isolation
vendor_id = Column(
Integer,
ForeignKey("vendors.id"),
nullable=True,
index=True,
)
# Status flags
is_closed = Column(Boolean, default=False, nullable=False)
closed_at = Column(DateTime, nullable=True)
closed_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
closed_by_id = Column(Integer, nullable=True)
# Last activity tracking for sorting
last_message_at = Column(DateTime, nullable=True, index=True)
message_count = Column(Integer, default=0, nullable=False)
# Relationships
vendor = relationship("Vendor", foreign_keys=[vendor_id])
participants = relationship(
"ConversationParticipant",
back_populates="conversation",
cascade="all, delete-orphan",
)
messages = relationship(
"Message",
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
# Indexes for common queries
__table_args__ = (
Index("ix_conversations_type_vendor", "conversation_type", "vendor_id"),
)
def __repr__(self) -> str:
return (
f"<Conversation(id={self.id}, type='{self.conversation_type.value}', "
f"subject='{self.subject[:30]}...')>"
)
class ConversationParticipant(Base, TimestampMixin):
"""
Links participants (users or customers) to conversations.
Polymorphic relationship:
- participant_type="admin" or "vendor": references users.id
- participant_type="customer": references customers.id
"""
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(
Integer,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Polymorphic participant reference
participant_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
participant_id = Column(Integer, nullable=False, index=True)
# For vendor participants, track which vendor they represent
vendor_id = Column(
Integer,
ForeignKey("vendors.id"),
nullable=True,
)
# Unread tracking per participant
unread_count = Column(Integer, default=0, nullable=False)
last_read_at = Column(DateTime, nullable=True)
# Notification preferences for this conversation
email_notifications = Column(Boolean, default=True, nullable=False)
muted = Column(Boolean, default=False, nullable=False)
# Relationships
conversation = relationship("Conversation", back_populates="participants")
vendor = relationship("Vendor", foreign_keys=[vendor_id])
__table_args__ = (
UniqueConstraint(
"conversation_id",
"participant_type",
"participant_id",
name="uq_conversation_participant",
),
Index(
"ix_participant_lookup",
"participant_type",
"participant_id",
),
)
def __repr__(self) -> str:
return (
f"<ConversationParticipant(conversation_id={self.conversation_id}, "
f"type='{self.participant_type.value}', id={self.participant_id})>"
)
class Message(Base, TimestampMixin):
"""
Individual message within a conversation thread.
Sender polymorphism follows same pattern as participant.
"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(
Integer,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Polymorphic sender reference
sender_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
sender_id = Column(Integer, nullable=False, index=True)
# Message content
content = Column(Text, nullable=False)
# System messages (e.g., "conversation closed")
is_system_message = Column(Boolean, default=False, nullable=False)
# Soft delete for moderation
is_deleted = Column(Boolean, default=False, nullable=False)
deleted_at = Column(DateTime, nullable=True)
deleted_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
deleted_by_id = Column(Integer, nullable=True)
# Relationships
conversation = relationship("Conversation", back_populates="messages")
attachments = relationship(
"MessageAttachment",
back_populates="message",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
)
def __repr__(self) -> str:
return (
f"<Message(id={self.id}, conversation_id={self.conversation_id}, "
f"sender={self.sender_type.value}:{self.sender_id})>"
)
class MessageAttachment(Base, TimestampMixin):
"""
File attachments for messages.
Files are stored in platform storage (local/S3) with references here.
"""
__tablename__ = "message_attachments"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(
Integer,
ForeignKey("messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# File metadata
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(1000), nullable=False) # Storage path
file_size = Column(Integer, nullable=False) # Size in bytes
mime_type = Column(String(100), nullable=False)
# For image attachments
is_image = Column(Boolean, default=False, nullable=False)
image_width = Column(Integer, nullable=True)
image_height = Column(Integer, nullable=True)
thumbnail_path = Column(String(1000), nullable=True)
# Relationships
message = relationship("Message", back_populates="attachments")
def __repr__(self) -> str:
return f"<MessageAttachment(id={self.id}, filename='{self.original_filename}')>"
__all__ = [
"Conversation",
"ConversationParticipant",
"ConversationType",
"Message",
"MessageAttachment",
"ParticipantType",
]

View File

@@ -1,406 +1,20 @@
# models/database/order.py
"""
Unified Order model for all sales channels.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports:
- Direct orders (from vendor's own storefront)
- Marketplace orders (Letzshop, etc.)
The canonical implementation is now in:
app/modules/orders/models/order.py
Design principles:
- Customer/address data is snapshotted at order time (preserves history)
- customer_id FK links to Customer record (may be inactive for marketplace imports)
- channel field distinguishes order source
- external_* fields store marketplace-specific references
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
from app.modules.orders.models import Order, OrderItem
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from typing import TYPE_CHECKING
from app.modules.orders.models.order import Order, OrderItem
if TYPE_CHECKING:
from models.database.order_item_exception import OrderItemException
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 Order(Base, TimestampMixin):
"""
Unified order model for all sales channels.
Stores orders from direct sales and marketplaces (Letzshop, etc.)
with snapshotted customer and address data.
All monetary amounts are stored as integer cents for precision.
"""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False, index=True)
customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True
)
order_number = Column(String(100), nullable=False, unique=True, index=True)
# === Channel/Source ===
channel = Column(
String(50), default="direct", nullable=False, index=True
) # direct, letzshop
# External references (for marketplace orders)
external_order_id = Column(
String(100), nullable=True, index=True
) # Marketplace order ID
external_shipment_id = Column(
String(100), nullable=True, index=True
) # Marketplace shipment ID
external_order_number = Column(String(100), nullable=True) # Marketplace order #
external_data = Column(JSON, nullable=True) # Raw marketplace data for debugging
# === Status ===
# pending: awaiting confirmation
# processing: confirmed, being prepared
# shipped: shipped with tracking
# delivered: delivered to customer
# cancelled: order cancelled/declined
# refunded: order refunded
status = Column(String(50), nullable=False, default="pending", index=True)
# === Financials (stored as integer cents) ===
subtotal_cents = Column(Integer, nullable=True) # May not be available from marketplace
tax_amount_cents = Column(Integer, nullable=True)
shipping_amount_cents = Column(Integer, nullable=True)
discount_amount_cents = Column(Integer, nullable=True)
total_amount_cents = Column(Integer, nullable=False)
currency = Column(String(10), default="EUR")
# === VAT Information ===
# VAT regime: domestic, oss, reverse_charge, origin, exempt
vat_regime = Column(String(20), nullable=True)
# VAT rate as percentage (e.g., 17.00 for 17%)
vat_rate = Column(Numeric(5, 2), nullable=True)
# Human-readable VAT label (e.g., "Luxembourg VAT 17%")
vat_rate_label = Column(String(100), nullable=True)
# Destination country for cross-border sales (ISO code)
vat_destination_country = Column(String(2), nullable=True)
# === Customer Snapshot (preserved at order time) ===
customer_first_name = Column(String(100), nullable=False)
customer_last_name = Column(String(100), nullable=False)
customer_email = Column(String(255), nullable=False)
customer_phone = Column(String(50), nullable=True)
customer_locale = Column(String(10), nullable=True) # en, fr, de, lb
# === Shipping Address Snapshot ===
ship_first_name = Column(String(100), nullable=False)
ship_last_name = Column(String(100), nullable=False)
ship_company = Column(String(200), nullable=True)
ship_address_line_1 = Column(String(255), nullable=False)
ship_address_line_2 = Column(String(255), nullable=True)
ship_city = Column(String(100), nullable=False)
ship_postal_code = Column(String(20), nullable=False)
ship_country_iso = Column(String(5), nullable=False)
# === Billing Address Snapshot ===
bill_first_name = Column(String(100), nullable=False)
bill_last_name = Column(String(100), nullable=False)
bill_company = Column(String(200), nullable=True)
bill_address_line_1 = Column(String(255), nullable=False)
bill_address_line_2 = Column(String(255), nullable=True)
bill_city = Column(String(100), nullable=False)
bill_postal_code = Column(String(20), nullable=False)
bill_country_iso = Column(String(5), nullable=False)
# === Tracking ===
shipping_method = Column(String(100), nullable=True)
tracking_number = Column(String(100), nullable=True)
tracking_provider = Column(String(100), nullable=True)
tracking_url = Column(String(500), nullable=True) # Full tracking URL
shipment_number = Column(String(100), nullable=True) # Carrier shipment number (e.g., H74683403433)
shipping_carrier = Column(String(50), nullable=True) # Carrier code (greco, colissimo, etc.)
# === Notes ===
customer_notes = Column(Text, nullable=True)
internal_notes = Column(Text, nullable=True)
# === Timestamps ===
order_date = Column(
DateTime(timezone=True), nullable=False
) # When customer placed order
confirmed_at = Column(DateTime(timezone=True), nullable=True)
shipped_at = Column(DateTime(timezone=True), nullable=True)
delivered_at = Column(DateTime(timezone=True), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
# === Relationships ===
vendor = relationship("Vendor")
customer = relationship("Customer", back_populates="orders")
items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
invoices = relationship(
"Invoice", back_populates="order", cascade="all, delete-orphan"
)
# Composite indexes for common queries
__table_args__ = (
Index("idx_order_vendor_status", "vendor_id", "status"),
Index("idx_order_vendor_channel", "vendor_id", "channel"),
Index("idx_order_vendor_date", "vendor_id", "order_date"),
)
def __repr__(self):
return f"<Order(id={self.id}, order_number='{self.order_number}', channel='{self.channel}', status='{self.status}')>"
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def subtotal(self) -> float | None:
"""Get subtotal in euros."""
if self.subtotal_cents is not None:
return cents_to_euros(self.subtotal_cents)
return None
@subtotal.setter
def subtotal(self, value: float | None):
"""Set subtotal from euros."""
self.subtotal_cents = euros_to_cents(value) if value is not None else None
@property
def tax_amount(self) -> float | None:
"""Get tax amount in euros."""
if self.tax_amount_cents is not None:
return cents_to_euros(self.tax_amount_cents)
return None
@tax_amount.setter
def tax_amount(self, value: float | None):
"""Set tax amount from euros."""
self.tax_amount_cents = euros_to_cents(value) if value is not None else None
@property
def shipping_amount(self) -> float | None:
"""Get shipping amount in euros."""
if self.shipping_amount_cents is not None:
return cents_to_euros(self.shipping_amount_cents)
return None
@shipping_amount.setter
def shipping_amount(self, value: float | None):
"""Set shipping amount from euros."""
self.shipping_amount_cents = euros_to_cents(value) if value is not None else None
@property
def discount_amount(self) -> float | None:
"""Get discount amount in euros."""
if self.discount_amount_cents is not None:
return cents_to_euros(self.discount_amount_cents)
return None
@discount_amount.setter
def discount_amount(self, value: float | None):
"""Set discount amount from euros."""
self.discount_amount_cents = euros_to_cents(value) if value is not None else None
@property
def total_amount(self) -> float:
"""Get total amount in euros."""
return cents_to_euros(self.total_amount_cents)
@total_amount.setter
def total_amount(self, value: float):
"""Set total amount from euros."""
self.total_amount_cents = euros_to_cents(value)
# === NAME PROPERTIES ===
@property
def customer_full_name(self) -> str:
"""Customer full name from snapshot."""
return f"{self.customer_first_name} {self.customer_last_name}".strip()
@property
def ship_full_name(self) -> str:
"""Shipping address full name."""
return f"{self.ship_first_name} {self.ship_last_name}".strip()
@property
def bill_full_name(self) -> str:
"""Billing address full name."""
return f"{self.bill_first_name} {self.bill_last_name}".strip()
@property
def is_marketplace_order(self) -> bool:
"""Check if this is a marketplace order."""
return self.channel != "direct"
@property
def is_fully_shipped(self) -> bool:
"""Check if all items are fully shipped."""
if not self.items:
return False
return all(item.is_fully_shipped for item in self.items)
@property
def is_partially_shipped(self) -> bool:
"""Check if some items are shipped but not all."""
if not self.items:
return False
has_shipped = any(item.shipped_quantity > 0 for item in self.items)
all_shipped = all(item.is_fully_shipped for item in self.items)
return has_shipped and not all_shipped
@property
def shipped_item_count(self) -> int:
"""Count of fully shipped items."""
return sum(1 for item in self.items if item.is_fully_shipped)
@property
def total_shipped_units(self) -> int:
"""Total quantity shipped across all items."""
return sum(item.shipped_quantity for item in self.items)
@property
def total_ordered_units(self) -> int:
"""Total quantity ordered across all items."""
return sum(item.quantity for item in self.items)
class OrderItem(Base, TimestampMixin):
"""
Individual items in an order.
Stores product snapshot at time of order plus external references
for marketplace items.
All monetary amounts are stored as integer cents for precision.
"""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# === Product Snapshot (preserved at order time) ===
product_name = Column(String(255), nullable=False)
product_sku = Column(String(100), nullable=True)
gtin = Column(String(50), nullable=True) # EAN/UPC/ISBN etc.
gtin_type = Column(String(20), nullable=True) # ean13, upc, isbn, etc.
# === Pricing (stored as integer cents) ===
quantity = Column(Integer, nullable=False)
unit_price_cents = Column(Integer, nullable=False)
total_price_cents = Column(Integer, nullable=False)
# === External References (for marketplace items) ===
external_item_id = Column(String(100), nullable=True) # e.g., Letzshop inventory unit ID
external_variant_id = Column(String(100), nullable=True) # e.g., Letzshop variant ID
# === Item State (for marketplace confirmation flow) ===
# confirmed_available: item confirmed and available
# confirmed_unavailable: item confirmed but not available (declined)
item_state = Column(String(50), nullable=True)
# === Inventory Tracking ===
inventory_reserved = Column(Boolean, default=False)
inventory_fulfilled = Column(Boolean, default=False)
# === Shipment Tracking ===
shipped_quantity = Column(Integer, default=0, nullable=False) # Units shipped so far
# === Exception Tracking ===
# True if product was not found by GTIN during import (linked to placeholder)
needs_product_match = Column(Boolean, default=False, index=True)
# === Relationships ===
order = relationship("Order", back_populates="items")
product = relationship("Product")
exception = relationship(
"OrderItemException",
back_populates="order_item",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<OrderItem(id={self.id}, order_id={self.order_id}, product_id={self.product_id}, gtin='{self.gtin}')>"
# === PRICE PROPERTIES (Euro convenience accessors) ===
@property
def unit_price(self) -> float:
"""Get unit price in euros."""
return cents_to_euros(self.unit_price_cents)
@unit_price.setter
def unit_price(self, value: float):
"""Set unit price from euros."""
self.unit_price_cents = euros_to_cents(value)
@property
def total_price(self) -> float:
"""Get total price in euros."""
return cents_to_euros(self.total_price_cents)
@total_price.setter
def total_price(self, value: float):
"""Set total price from euros."""
self.total_price_cents = euros_to_cents(value)
# === STATUS PROPERTIES ===
@property
def is_confirmed(self) -> bool:
"""Check if item has been confirmed (available or unavailable)."""
return self.item_state in ("confirmed_available", "confirmed_unavailable")
@property
def is_available(self) -> bool:
"""Check if item is confirmed as available."""
return self.item_state == "confirmed_available"
@property
def is_declined(self) -> bool:
"""Check if item was declined (unavailable)."""
return self.item_state == "confirmed_unavailable"
@property
def has_unresolved_exception(self) -> bool:
"""Check if item has an unresolved exception blocking confirmation."""
if not self.exception:
return False
return self.exception.blocks_confirmation
# === SHIPMENT PROPERTIES ===
@property
def remaining_quantity(self) -> int:
"""Quantity not yet shipped."""
return max(0, self.quantity - self.shipped_quantity)
@property
def is_fully_shipped(self) -> bool:
"""Check if all units have been shipped."""
return self.shipped_quantity >= self.quantity
@property
def is_partially_shipped(self) -> bool:
"""Check if some but not all units have been shipped."""
return 0 < self.shipped_quantity < self.quantity
__all__ = [
"Order",
"OrderItem",
]

View File

@@ -1,117 +1,19 @@
# models/database/order_item_exception.py
"""
Order Item Exception model for tracking unmatched products during marketplace imports.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
When a marketplace order contains a GTIN that doesn't match any product in the
vendor's catalog, the order is still imported but the item is linked to a
placeholder product and an exception is recorded here for resolution.
The canonical implementation is now in:
app/modules/orders/models/order_item_exception.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.orders.models import OrderItemException
"""
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.modules.orders.models.order_item_exception import OrderItemException
from app.core.database import Base
from models.database.base import TimestampMixin
class OrderItemException(Base, TimestampMixin):
"""
Tracks unmatched order items requiring admin/vendor resolution.
When a marketplace order is imported and a product cannot be found by GTIN,
the order item is linked to a placeholder product and this exception record
is created. The order cannot be confirmed until all exceptions are resolved.
"""
__tablename__ = "order_item_exceptions"
id = Column(Integer, primary_key=True, index=True)
# Link to the order item (one-to-one)
order_item_id = Column(
Integer,
ForeignKey("order_items.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# Vendor ID for efficient querying (denormalized from order)
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False, index=True
)
# Original data from marketplace (preserved for matching)
original_gtin = Column(String(50), nullable=True, index=True)
original_product_name = Column(String(500), nullable=True)
original_sku = Column(String(100), nullable=True)
# Exception classification
# product_not_found: GTIN not in vendor catalog
# gtin_mismatch: GTIN format issue
# duplicate_gtin: Multiple products with same GTIN
exception_type = Column(
String(50), nullable=False, default="product_not_found"
)
# Resolution status
# pending: Awaiting resolution
# resolved: Product has been assigned
# ignored: Marked as ignored (still blocks confirmation)
status = Column(String(50), nullable=False, default="pending", index=True)
# Resolution details (populated when resolved)
resolved_product_id = Column(
Integer, ForeignKey("products.id"), nullable=True
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution_notes = Column(Text, nullable=True)
# Relationships
order_item = relationship("OrderItem", back_populates="exception")
vendor = relationship("Vendor")
resolved_product = relationship("Product")
resolver = relationship("User")
# Composite indexes for common queries
__table_args__ = (
Index("idx_exception_vendor_status", "vendor_id", "status"),
Index("idx_exception_gtin", "vendor_id", "original_gtin"),
)
def __repr__(self):
return (
f"<OrderItemException(id={self.id}, "
f"order_item_id={self.order_item_id}, "
f"gtin='{self.original_gtin}', "
f"status='{self.status}')>"
)
@property
def is_pending(self) -> bool:
"""Check if exception is pending resolution."""
return self.status == "pending"
@property
def is_resolved(self) -> bool:
"""Check if exception has been resolved."""
return self.status == "resolved"
@property
def is_ignored(self) -> bool:
"""Check if exception has been ignored."""
return self.status == "ignored"
@property
def blocks_confirmation(self) -> bool:
"""Check if this exception blocks order confirmation."""
# Both pending and ignored exceptions block confirmation
return self.status in ("pending", "ignored")
__all__ = [
"OrderItemException",
]

View File

@@ -1,85 +1,19 @@
import hashlib
import secrets
from datetime import datetime, timedelta
# models/database/password_reset_token.py
"""
LEGACY LOCATION - Re-exports from module for backwards compatibility.
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
The canonical implementation is now in:
app/modules/customers/models/password_reset_token.py
from app.core.database import Base
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.customers.models import PasswordResetToken
"""
class PasswordResetToken(Base):
"""Password reset token for customer accounts.
from app.modules.customers.models.password_reset_token import PasswordResetToken
Security:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 1 hour
- Only one active token per customer (old tokens invalidated on new request)
"""
__tablename__ = "password_reset_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 1
id = Column(Integer, primary_key=True, index=True)
customer_id = Column(Integer, ForeignKey("customers.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
customer = relationship("Customer")
def __repr__(self):
return f"<PasswordResetToken(id={self.id}, customer_id={self.customer_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_customer(cls, db: Session, customer_id: int) -> str:
"""Create a new password reset token for a customer.
Invalidates any existing tokens for the customer.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this customer
db.query(cls).filter(
cls.customer_id == customer_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
customer_id=customer_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "PasswordResetToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()
__all__ = [
"PasswordResetToken",
]

View File

@@ -1,333 +1,69 @@
# models/schema/customer.py
"""
Pydantic schema for customer-related operations.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
The canonical implementation is now in:
app/modules/customers/schemas/customer.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.customers.schemas import CustomerRegister, CustomerResponse
"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, EmailStr, Field, field_validator
# ============================================================================
# Customer Registration & Authentication
# ============================================================================
class CustomerRegister(BaseModel):
"""Schema for customer registration."""
email: EmailStr = Field(..., description="Customer email address")
password: str = Field(
..., min_length=8, description="Password (minimum 8 characters)"
)
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool = Field(default=False)
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
"""Convert email to lowercase."""
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isalpha() for char in v):
raise ValueError("Password must contain at least one letter")
return v
class CustomerUpdate(BaseModel):
"""Schema for updating customer profile."""
email: EmailStr | None = None
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str | None) -> str | None:
"""Convert email to lowercase."""
return v.lower() if v else None
class CustomerPasswordChange(BaseModel):
"""Schema for customer password change."""
current_password: str = Field(..., description="Current password")
new_password: str = Field(
..., min_length=8, description="New password (minimum 8 characters)"
)
confirm_password: str = Field(..., description="Confirm new password")
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isalpha() for char in v):
raise ValueError("Password must contain at least one letter")
return v
# ============================================================================
# Customer Response
# ============================================================================
class CustomerResponse(BaseModel):
"""Schema for customer response (excludes password)."""
id: int
vendor_id: int
email: str
first_name: str | None
last_name: str | None
phone: str | None
customer_number: str
marketing_consent: bool
preferred_language: str | None
last_order_date: datetime | None
total_orders: int
total_spent: Decimal
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@property
def full_name(self) -> str:
"""Get customer full name."""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
return self.email
class CustomerListResponse(BaseModel):
"""Schema for paginated customer list."""
customers: list[CustomerResponse]
total: int
page: int
per_page: int
total_pages: int
# ============================================================================
# Customer Address
# ============================================================================
class CustomerAddressCreate(BaseModel):
"""Schema for creating customer address."""
address_type: str = Field(..., pattern="^(billing|shipping)$")
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: str | None = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country_name: str = Field(..., min_length=2, max_length=100)
country_iso: str = Field(..., min_length=2, max_length=5)
is_default: bool = Field(default=False)
class CustomerAddressUpdate(BaseModel):
"""Schema for updating customer address."""
address_type: str | None = Field(None, pattern="^(billing|shipping)$")
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
company: str | None = Field(None, max_length=200)
address_line_1: str | None = Field(None, min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str | None = Field(None, min_length=1, max_length=100)
postal_code: str | None = Field(None, min_length=1, max_length=20)
country_name: str | None = Field(None, min_length=2, max_length=100)
country_iso: str | None = Field(None, min_length=2, max_length=5)
is_default: bool | None = None
class CustomerAddressResponse(BaseModel):
"""Schema for customer address response."""
id: int
vendor_id: int
customer_id: int
address_type: str
first_name: str
last_name: str
company: str | None
address_line_1: str
address_line_2: str | None
city: str
postal_code: str
country_name: str
country_iso: str
is_default: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CustomerAddressListResponse(BaseModel):
"""Schema for customer address list response."""
addresses: list[CustomerAddressResponse]
total: int
# ============================================================================
# Customer Preferences
# ============================================================================
class CustomerPreferencesUpdate(BaseModel):
"""Schema for updating customer preferences."""
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
)
currency: str | None = Field(None, max_length=3)
notification_preferences: dict[str, bool] | None = None
# ============================================================================
# Vendor Customer Management Response Schemas
# ============================================================================
class CustomerMessageResponse(BaseModel):
"""Simple message response for customer operations."""
message: str
class VendorCustomerListResponse(BaseModel):
"""Schema for vendor customer list with skip/limit pagination."""
customers: list[CustomerResponse] = []
total: int = 0
skip: int = 0
limit: int = 100
message: str | None = None
class CustomerDetailResponse(BaseModel):
"""Detailed customer response for vendor management."""
id: int | None = None
vendor_id: int | None = None
email: str | None = None
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int | None = None
total_spent: Decimal | None = None
is_active: bool | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
message: str | None = None
model_config = {"from_attributes": True}
class CustomerOrderInfo(BaseModel):
"""Basic order info for customer order history."""
id: int
order_number: str
status: str
total: Decimal
created_at: datetime
class CustomerOrdersResponse(BaseModel):
"""Response for customer order history."""
orders: list[CustomerOrderInfo] = []
total: int = 0
message: str | None = None
class CustomerStatisticsResponse(BaseModel):
"""Response for customer statistics."""
total: int = 0
active: int = 0
inactive: int = 0
with_orders: int = 0
total_spent: float = 0.0
total_orders: int = 0
avg_order_value: float = 0.0
# ============================================================================
# Admin Customer Management Response Schemas
# ============================================================================
class AdminCustomerItem(BaseModel):
"""Admin customer list item with vendor info."""
id: int
vendor_id: int
email: str
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
customer_number: str
marketing_consent: bool = False
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int = 0
total_spent: float = 0.0
is_active: bool = True
created_at: datetime
updated_at: datetime
vendor_name: str | None = None
vendor_code: str | None = None
model_config = {"from_attributes": True}
class CustomerListResponse(BaseModel):
"""Admin paginated customer list with skip/limit."""
customers: list[AdminCustomerItem] = []
total: int = 0
skip: int = 0
limit: int = 20
class CustomerDetailResponse(AdminCustomerItem):
"""Detailed customer response for admin."""
pass
from app.modules.customers.schemas.customer import (
# Registration & Authentication
CustomerRegister,
CustomerUpdate,
CustomerPasswordChange,
# Customer Response
CustomerResponse,
CustomerListResponse,
# Address
CustomerAddressCreate,
CustomerAddressUpdate,
CustomerAddressResponse,
CustomerAddressListResponse,
# Preferences
CustomerPreferencesUpdate,
# Vendor Management
CustomerMessageResponse,
VendorCustomerListResponse,
CustomerDetailResponse,
CustomerOrderInfo,
CustomerOrdersResponse,
CustomerStatisticsResponse,
# Admin Management
AdminCustomerItem,
AdminCustomerListResponse,
AdminCustomerDetailResponse,
)
__all__ = [
# Registration & Authentication
"CustomerRegister",
"CustomerUpdate",
"CustomerPasswordChange",
# Customer Response
"CustomerResponse",
"CustomerListResponse",
# Address
"CustomerAddressCreate",
"CustomerAddressUpdate",
"CustomerAddressResponse",
"CustomerAddressListResponse",
# Preferences
"CustomerPreferencesUpdate",
# Vendor Management
"CustomerMessageResponse",
"VendorCustomerListResponse",
"CustomerDetailResponse",
"CustomerOrderInfo",
"CustomerOrdersResponse",
"CustomerStatisticsResponse",
# Admin Management
"AdminCustomerItem",
"AdminCustomerListResponse",
"AdminCustomerDetailResponse",
]

View File

@@ -1,294 +1,85 @@
# models/schema/inventory.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class InventoryBase(BaseModel):
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
class InventoryCreate(InventoryBase):
"""Set exact inventory quantity (replaces existing)."""
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
class InventoryAdjust(InventoryBase):
"""Add or remove inventory quantity."""
quantity: int = Field(
..., description="Quantity to add (positive) or remove (negative)"
)
class InventoryUpdate(BaseModel):
"""Update inventory fields."""
quantity: int | None = Field(None, ge=0)
reserved_quantity: int | None = Field(None, ge=0)
location: str | None = None
class InventoryReserve(BaseModel):
"""Reserve inventory for orders."""
product_id: int
location: str
quantity: int = Field(..., gt=0)
class InventoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
vendor_id: int
location: str
quantity: int
reserved_quantity: int
gtin: str | None
created_at: datetime
updated_at: datetime
@property
def available_quantity(self):
return max(0, self.quantity - self.reserved_quantity)
class InventoryLocationResponse(BaseModel):
location: str
quantity: int
reserved_quantity: int
available_quantity: int
class ProductInventorySummary(BaseModel):
"""Inventory summary for a product."""
product_id: int
vendor_id: int
product_sku: str | None
product_title: str
total_quantity: int
total_reserved: int
total_available: int
locations: list[InventoryLocationResponse]
class InventoryListResponse(BaseModel):
inventories: list[InventoryResponse]
total: int
skip: int
limit: int
class InventoryMessageResponse(BaseModel):
"""Simple message response for inventory operations."""
message: str
class InventorySummaryResponse(BaseModel):
"""Inventory summary response for marketplace product service."""
gtin: str
total_quantity: int
locations: list[InventoryLocationResponse]
# ============================================================================
# Admin Inventory Schemas
# ============================================================================
class AdminInventoryCreate(BaseModel):
"""Admin version of inventory create - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
class AdminInventoryAdjust(BaseModel):
"""Admin version of inventory adjust - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
quantity: int = Field(
..., description="Quantity to add (positive) or remove (negative)"
)
reason: str | None = Field(None, description="Reason for adjustment")
class AdminInventoryItem(BaseModel):
"""Inventory item with vendor info for admin list view."""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
vendor_id: int
vendor_name: str | None = None
vendor_code: str | None = None
product_title: str | None = None
product_sku: str | None = None
location: str
quantity: int
reserved_quantity: int
available_quantity: int
gtin: str | None = None
created_at: datetime
updated_at: datetime
class AdminInventoryListResponse(BaseModel):
"""Cross-vendor inventory list for admin."""
inventories: list[AdminInventoryItem]
total: int
skip: int
limit: int
vendor_filter: int | None = None
location_filter: str | None = None
class AdminInventoryStats(BaseModel):
"""Inventory statistics for admin dashboard."""
total_entries: int
total_quantity: int
total_reserved: int
total_available: int
low_stock_count: int
vendors_with_inventory: int
unique_locations: int
class AdminLowStockItem(BaseModel):
"""Low stock item for admin alerts."""
id: int
product_id: int
vendor_id: int
vendor_name: str | None = None
product_title: str | None = None
location: str
quantity: int
reserved_quantity: int
available_quantity: int
class AdminVendorWithInventory(BaseModel):
"""Vendor with inventory entries."""
id: int
name: str
vendor_code: str
class AdminVendorsWithInventoryResponse(BaseModel):
"""Response for vendors with inventory list."""
vendors: list[AdminVendorWithInventory]
class AdminInventoryLocationsResponse(BaseModel):
"""Response for unique inventory locations."""
locations: list[str]
# ============================================================================
# Inventory Transaction Schemas
# ============================================================================
class InventoryTransactionResponse(BaseModel):
"""Single inventory transaction record."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
product_id: int
inventory_id: int | None = None
transaction_type: str
quantity_change: int
quantity_after: int
reserved_after: int
location: str | None = None
warehouse: str | None = None
order_id: int | None = None
order_number: str | None = None
reason: str | None = None
created_by: str | None = None
created_at: datetime
class InventoryTransactionWithProduct(InventoryTransactionResponse):
"""Transaction with product details for list views."""
product_title: str | None = None
product_sku: str | None = None
class InventoryTransactionListResponse(BaseModel):
"""Paginated list of inventory transactions."""
transactions: list[InventoryTransactionWithProduct]
total: int
skip: int
limit: int
class ProductTransactionHistoryResponse(BaseModel):
"""Transaction history for a specific product."""
product_id: int
product_title: str | None = None
product_sku: str | None = None
current_quantity: int
current_reserved: int
transactions: list[InventoryTransactionResponse]
total: int
class OrderTransactionHistoryResponse(BaseModel):
"""Transaction history for a specific order."""
order_id: int
order_number: str
transactions: list[InventoryTransactionWithProduct]
# ============================================================================
# Admin Inventory Transaction Schemas
# ============================================================================
class AdminInventoryTransactionItem(InventoryTransactionWithProduct):
"""Transaction with vendor details for admin views."""
vendor_name: str | None = None
vendor_code: str | None = None
class AdminInventoryTransactionListResponse(BaseModel):
"""Paginated list of transactions for admin."""
transactions: list[AdminInventoryTransactionItem]
total: int
skip: int
limit: int
class AdminTransactionStatsResponse(BaseModel):
"""Transaction statistics for admin dashboard."""
total_transactions: int
transactions_today: int
by_type: dict[str, int]
"""
LEGACY LOCATION - Re-exports from module for backwards compatibility.
The canonical implementation is now in:
app/modules/inventory/schemas/inventory.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.inventory.schemas import InventoryCreate, InventoryResponse
"""
from app.modules.inventory.schemas.inventory import (
# Base schemas
InventoryBase,
InventoryCreate,
InventoryAdjust,
InventoryUpdate,
InventoryReserve,
# Response schemas
InventoryResponse,
InventoryLocationResponse,
ProductInventorySummary,
InventoryListResponse,
InventoryMessageResponse,
InventorySummaryResponse,
# Admin schemas
AdminInventoryCreate,
AdminInventoryAdjust,
AdminInventoryItem,
AdminInventoryListResponse,
AdminInventoryStats,
AdminLowStockItem,
AdminVendorWithInventory,
AdminVendorsWithInventoryResponse,
AdminInventoryLocationsResponse,
# Transaction schemas
InventoryTransactionResponse,
InventoryTransactionWithProduct,
InventoryTransactionListResponse,
ProductTransactionHistoryResponse,
OrderTransactionHistoryResponse,
# Admin transaction schemas
AdminInventoryTransactionItem,
AdminInventoryTransactionListResponse,
AdminTransactionStatsResponse,
)
__all__ = [
# Base schemas
"InventoryBase",
"InventoryCreate",
"InventoryAdjust",
"InventoryUpdate",
"InventoryReserve",
# Response schemas
"InventoryResponse",
"InventoryLocationResponse",
"ProductInventorySummary",
"InventoryListResponse",
"InventoryMessageResponse",
"InventorySummaryResponse",
# Admin schemas
"AdminInventoryCreate",
"AdminInventoryAdjust",
"AdminInventoryItem",
"AdminInventoryListResponse",
"AdminInventoryStats",
"AdminLowStockItem",
"AdminVendorWithInventory",
"AdminVendorsWithInventoryResponse",
"AdminInventoryLocationsResponse",
# Transaction schemas
"InventoryTransactionResponse",
"InventoryTransactionWithProduct",
"InventoryTransactionListResponse",
"ProductTransactionHistoryResponse",
"OrderTransactionHistoryResponse",
# Admin transaction schemas
"AdminInventoryTransactionItem",
"AdminInventoryTransactionListResponse",
"AdminTransactionStatsResponse",
]

View File

@@ -1,310 +1,61 @@
# models/schema/invoice.py
"""
Pydantic schemas for invoice operations.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports invoice settings management and invoice generation.
The canonical implementation is now in:
app/modules/orders/schemas/invoice.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.orders.schemas import invoice
"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Invoice Settings Schemas
# ============================================================================
class VendorInvoiceSettingsCreate(BaseModel):
"""Schema for creating vendor invoice settings."""
company_name: str = Field(..., min_length=1, max_length=255)
company_address: str | None = Field(None, max_length=255)
company_city: str | None = Field(None, max_length=100)
company_postal_code: str | None = Field(None, max_length=20)
company_country: str = Field(default="LU", min_length=2, max_length=2)
vat_number: str | None = Field(None, max_length=50)
is_vat_registered: bool = True
is_oss_registered: bool = False
oss_registration_country: str | None = Field(None, min_length=2, max_length=2)
invoice_prefix: str = Field(default="INV", max_length=20)
invoice_number_padding: int = Field(default=5, ge=1, le=10)
payment_terms: str | None = None
bank_name: str | None = Field(None, max_length=255)
bank_iban: str | None = Field(None, max_length=50)
bank_bic: str | None = Field(None, max_length=20)
footer_text: str | None = None
default_vat_rate: Decimal = Field(default=Decimal("17.00"), ge=0, le=100)
class VendorInvoiceSettingsUpdate(BaseModel):
"""Schema for updating vendor invoice settings."""
company_name: str | None = Field(None, min_length=1, max_length=255)
company_address: str | None = Field(None, max_length=255)
company_city: str | None = Field(None, max_length=100)
company_postal_code: str | None = Field(None, max_length=20)
company_country: str | None = Field(None, min_length=2, max_length=2)
vat_number: str | None = None
is_vat_registered: bool | None = None
is_oss_registered: bool | None = None
oss_registration_country: str | None = None
invoice_prefix: str | None = Field(None, max_length=20)
invoice_number_padding: int | None = Field(None, ge=1, le=10)
payment_terms: str | None = None
bank_name: str | None = Field(None, max_length=255)
bank_iban: str | None = Field(None, max_length=50)
bank_bic: str | None = Field(None, max_length=20)
footer_text: str | None = None
default_vat_rate: Decimal | None = Field(None, ge=0, le=100)
class VendorInvoiceSettingsResponse(BaseModel):
"""Schema for vendor invoice settings response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
company_name: str
company_address: str | None
company_city: str | None
company_postal_code: str | None
company_country: str
vat_number: str | None
is_vat_registered: bool
is_oss_registered: bool
oss_registration_country: str | None
invoice_prefix: str
invoice_next_number: int
invoice_number_padding: int
payment_terms: str | None
bank_name: str | None
bank_iban: str | None
bank_bic: str | None
footer_text: str | None
default_vat_rate: Decimal
created_at: datetime
updated_at: datetime
# ============================================================================
# Invoice Line Item Schemas
# ============================================================================
class InvoiceLineItem(BaseModel):
"""Schema for invoice line item."""
description: str
quantity: int = Field(..., ge=1)
unit_price_cents: int
total_cents: int
sku: str | None = None
ean: str | None = None
class InvoiceLineItemResponse(BaseModel):
"""Schema for invoice line item in response."""
description: str
quantity: int
unit_price_cents: int
total_cents: int
sku: str | None = None
ean: str | None = None
@property
def unit_price(self) -> float:
return self.unit_price_cents / 100
@property
def total(self) -> float:
return self.total_cents / 100
# ============================================================================
# Invoice Address Schemas
# ============================================================================
class InvoiceSellerDetails(BaseModel):
"""Seller details for invoice."""
company_name: str
address: str | None = None
city: str | None = None
postal_code: str | None = None
country: str
vat_number: str | None = None
class InvoiceBuyerDetails(BaseModel):
"""Buyer details for invoice."""
name: str
email: str | None = None
address: str | None = None
city: str | None = None
postal_code: str | None = None
country: str
vat_number: str | None = None # For B2B
# ============================================================================
# Invoice Schemas
# ============================================================================
class InvoiceCreate(BaseModel):
"""Schema for creating an invoice from an order."""
order_id: int
notes: str | None = None
class InvoiceManualCreate(BaseModel):
"""Schema for creating a manual invoice (without order)."""
buyer_details: InvoiceBuyerDetails
line_items: list[InvoiceLineItem]
notes: str | None = None
payment_terms: str | None = None
class InvoiceResponse(BaseModel):
"""Schema for invoice response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
order_id: int | None
invoice_number: str
invoice_date: datetime
status: str
seller_details: dict
buyer_details: dict
line_items: list[dict]
vat_regime: str
destination_country: str | None
vat_rate: Decimal
vat_rate_label: str | None
currency: str
subtotal_cents: int
vat_amount_cents: int
total_cents: int
payment_terms: str | None
bank_details: dict | None
footer_text: str | None
pdf_generated_at: datetime | None
pdf_path: str | None
notes: str | None
created_at: datetime
updated_at: datetime
@property
def subtotal(self) -> float:
return self.subtotal_cents / 100
@property
def vat_amount(self) -> float:
return self.vat_amount_cents / 100
@property
def total(self) -> float:
return self.total_cents / 100
class InvoiceListResponse(BaseModel):
"""Schema for invoice list response (summary)."""
model_config = ConfigDict(from_attributes=True)
id: int
invoice_number: str
invoice_date: datetime
status: str
currency: str
total_cents: int
order_id: int | None
# Buyer name for display
buyer_name: str | None = None
@property
def total(self) -> float:
return self.total_cents / 100
class InvoiceStatusUpdate(BaseModel):
"""Schema for updating invoice status."""
status: str = Field(..., pattern="^(draft|issued|paid|cancelled)$")
# ============================================================================
# Paginated Response
# ============================================================================
class InvoiceListPaginatedResponse(BaseModel):
"""Paginated invoice list response."""
items: list[InvoiceListResponse]
total: int
page: int
per_page: int
pages: int
# ============================================================================
# PDF Response
# ============================================================================
class InvoicePDFGeneratedResponse(BaseModel):
"""Response for PDF generation."""
pdf_path: str
message: str = "PDF generated successfully"
class InvoiceStatsResponse(BaseModel):
"""Invoice statistics response."""
total_invoices: int
total_revenue_cents: int
draft_count: int
issued_count: int
paid_count: int
cancelled_count: int
@property
def total_revenue(self) -> float:
return self.total_revenue_cents / 100
from app.modules.orders.schemas.invoice import (
# Invoice settings schemas
VendorInvoiceSettingsCreate,
VendorInvoiceSettingsUpdate,
VendorInvoiceSettingsResponse,
# Line item schemas
InvoiceLineItem,
InvoiceLineItemResponse,
# Address schemas
InvoiceSellerDetails,
InvoiceBuyerDetails,
# Invoice CRUD schemas
InvoiceCreate,
InvoiceManualCreate,
InvoiceResponse,
InvoiceListResponse,
InvoiceStatusUpdate,
# Pagination
InvoiceListPaginatedResponse,
# PDF
InvoicePDFGeneratedResponse,
InvoiceStatsResponse,
)
__all__ = [
# Invoice settings schemas
"VendorInvoiceSettingsCreate",
"VendorInvoiceSettingsUpdate",
"VendorInvoiceSettingsResponse",
# Line item schemas
"InvoiceLineItem",
"InvoiceLineItemResponse",
# Address schemas
"InvoiceSellerDetails",
"InvoiceBuyerDetails",
# Invoice CRUD schemas
"InvoiceCreate",
"InvoiceManualCreate",
"InvoiceResponse",
"InvoiceListResponse",
"InvoiceStatusUpdate",
# Pagination
"InvoiceListPaginatedResponse",
# PDF
"InvoicePDFGeneratedResponse",
"InvoiceStatsResponse",
]

View File

@@ -1,308 +1,83 @@
# models/schema/message.py
"""
Pydantic schemas for the messaging system.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports three communication channels:
- Admin <-> Vendor
- Vendor <-> Customer
- Admin <-> Customer
The canonical implementation is now in:
app/modules/messaging/schemas/message.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.messaging.schemas import message
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from models.database.message import ConversationType, ParticipantType
# ============================================================================
# Attachment Schemas
# ============================================================================
class AttachmentResponse(BaseModel):
"""Schema for message attachment in responses."""
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
original_filename: str
file_size: int
mime_type: str
is_image: bool
image_width: int | None = None
image_height: int | None = None
download_url: str | None = None
thumbnail_url: str | None = None
@property
def file_size_display(self) -> str:
"""Human-readable file size."""
if self.file_size < 1024:
return f"{self.file_size} B"
elif self.file_size < 1024 * 1024:
return f"{self.file_size / 1024:.1f} KB"
else:
return f"{self.file_size / 1024 / 1024:.1f} MB"
# ============================================================================
# Message Schemas
# ============================================================================
class MessageCreate(BaseModel):
"""Schema for sending a new message."""
content: str = Field(..., min_length=1, max_length=10000)
class MessageResponse(BaseModel):
"""Schema for a single message in responses."""
model_config = ConfigDict(from_attributes=True)
id: int
conversation_id: int
sender_type: ParticipantType
sender_id: int
content: str
is_system_message: bool
is_deleted: bool
created_at: datetime
# Enriched sender info (populated by API)
sender_name: str | None = None
sender_email: str | None = None
# Attachments
attachments: list[AttachmentResponse] = []
# ============================================================================
# Participant Schemas
# ============================================================================
class ParticipantInfo(BaseModel):
"""Schema for participant information."""
id: int
type: ParticipantType
name: str
email: str | None = None
avatar_url: str | None = None
class ParticipantResponse(BaseModel):
"""Schema for conversation participant in responses."""
model_config = ConfigDict(from_attributes=True)
id: int
participant_type: ParticipantType
participant_id: int
unread_count: int
last_read_at: datetime | None
email_notifications: bool
muted: bool
# Enriched info (populated by API)
participant_info: ParticipantInfo | None = None
# ============================================================================
# Conversation Schemas
# ============================================================================
class ConversationCreate(BaseModel):
"""Schema for creating a new conversation."""
conversation_type: ConversationType
subject: str = Field(..., min_length=1, max_length=500)
recipient_type: ParticipantType
recipient_id: int
vendor_id: int | None = None
initial_message: str | None = Field(None, min_length=1, max_length=10000)
class ConversationSummary(BaseModel):
"""Schema for conversation in list views."""
model_config = ConfigDict(from_attributes=True)
id: int
conversation_type: ConversationType
subject: str
vendor_id: int | None = None
is_closed: bool
closed_at: datetime | None
last_message_at: datetime | None
message_count: int
created_at: datetime
# Unread count for current user (from participant)
unread_count: int = 0
# Other participant info (enriched by API)
other_participant: ParticipantInfo | None = None
# Last message preview
last_message_preview: str | None = None
class ConversationDetailResponse(BaseModel):
"""Schema for full conversation detail with messages."""
model_config = ConfigDict(from_attributes=True)
id: int
conversation_type: ConversationType
subject: str
vendor_id: int | None = None
is_closed: bool
closed_at: datetime | None
closed_by_type: ParticipantType | None = None
closed_by_id: int | None = None
last_message_at: datetime | None
message_count: int
created_at: datetime
updated_at: datetime
# Participants with enriched info
participants: list[ParticipantResponse] = []
# Messages ordered by created_at
messages: list[MessageResponse] = []
# Current user's unread count
unread_count: int = 0
# Vendor info if applicable
vendor_name: str | None = None
class ConversationListResponse(BaseModel):
"""Schema for paginated conversation list."""
conversations: list[ConversationSummary]
total: int
total_unread: int
skip: int
limit: int
# ============================================================================
# Unread Count Schemas
# ============================================================================
class UnreadCountResponse(BaseModel):
"""Schema for unread message count (for header badge)."""
total_unread: int
# ============================================================================
# Notification Preferences Schemas
# ============================================================================
class NotificationPreferencesUpdate(BaseModel):
"""Schema for updating notification preferences."""
email_notifications: bool | None = None
muted: bool | None = None
# ============================================================================
# Conversation Action Schemas
# ============================================================================
class CloseConversationResponse(BaseModel):
"""Response after closing a conversation."""
success: bool
message: str
conversation_id: int
class ReopenConversationResponse(BaseModel):
"""Response after reopening a conversation."""
success: bool
message: str
conversation_id: int
class MarkReadResponse(BaseModel):
"""Response after marking conversation as read."""
success: bool
conversation_id: int
unread_count: int
# ============================================================================
# Recipient Selection Schemas (for compose modal)
# ============================================================================
class RecipientOption(BaseModel):
"""Schema for a selectable recipient in compose modal."""
id: int
type: ParticipantType
name: str
email: str | None = None
vendor_id: int | None = None # For vendor users
vendor_name: str | None = None
class RecipientListResponse(BaseModel):
"""Schema for list of available recipients."""
recipients: list[RecipientOption]
total: int
# ============================================================================
# Admin-specific Schemas
# ============================================================================
class AdminConversationSummary(ConversationSummary):
"""Extended conversation summary with vendor info for admin views."""
vendor_name: str | None = None
vendor_code: str | None = None
class AdminConversationListResponse(BaseModel):
"""Schema for admin conversation list with vendor info."""
conversations: list[AdminConversationSummary]
total: int
total_unread: int
skip: int
limit: int
class AdminMessageStats(BaseModel):
"""Messaging statistics for admin dashboard."""
total_conversations: int = 0
open_conversations: int = 0
closed_conversations: int = 0
total_messages: int = 0
# By type
admin_vendor_conversations: int = 0
vendor_customer_conversations: int = 0
admin_customer_conversations: int = 0
# Unread
unread_admin: int = 0
from app.modules.messaging.schemas.message import (
# Attachment schemas
AttachmentResponse,
# Message schemas
MessageCreate,
MessageResponse,
# Participant schemas
ParticipantInfo,
ParticipantResponse,
# Conversation schemas
ConversationCreate,
ConversationSummary,
ConversationDetailResponse,
ConversationListResponse,
ConversationResponse,
# Unread count
UnreadCountResponse,
# Notification preferences
NotificationPreferencesUpdate,
# Conversation actions
CloseConversationResponse,
ReopenConversationResponse,
MarkReadResponse,
# Recipient selection
RecipientOption,
RecipientListResponse,
# Admin schemas
AdminConversationSummary,
AdminConversationListResponse,
AdminMessageStats,
)
# Re-export enums from models for backward compatibility
from app.modules.messaging.models.message import ConversationType, ParticipantType
__all__ = [
# Attachment schemas
"AttachmentResponse",
# Message schemas
"MessageCreate",
"MessageResponse",
# Participant schemas
"ParticipantInfo",
"ParticipantResponse",
# Conversation schemas
"ConversationCreate",
"ConversationSummary",
"ConversationDetailResponse",
"ConversationListResponse",
"ConversationResponse",
# Unread count
"UnreadCountResponse",
# Notification preferences
"NotificationPreferencesUpdate",
# Conversation actions
"CloseConversationResponse",
"ReopenConversationResponse",
"MarkReadResponse",
# Recipient selection
"RecipientOption",
"RecipientListResponse",
# Admin schemas
"AdminConversationSummary",
"AdminConversationListResponse",
"AdminMessageStats",
# Enums
"ConversationType",
"ParticipantType",
]

View File

@@ -1,152 +1,53 @@
# models/schema/notification.py
"""
Notification Pydantic schemas for API validation and responses.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
This module provides schemas for:
- Vendor notifications (list, read, delete)
- Notification settings management
- Notification email templates
- Unread counts and statistics
The canonical implementation is now in:
app/modules/messaging/schemas/notification.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.messaging.schemas import notification
"""
from datetime import datetime
from typing import Any
from app.modules.messaging.schemas.notification import (
# Response schemas
MessageResponse,
UnreadCountResponse,
# Notification schemas
NotificationResponse,
NotificationListResponse,
# Settings schemas
NotificationSettingsResponse,
NotificationSettingsUpdate,
# Template schemas
NotificationTemplateResponse,
NotificationTemplateListResponse,
NotificationTemplateUpdate,
# Test notification
TestNotificationRequest,
# Alert statistics
AlertStatisticsResponse,
)
from pydantic import BaseModel, Field
# ============================================================================
# SHARED RESPONSE SCHEMAS
# ============================================================================
class MessageResponse(BaseModel):
"""Generic message response for simple operations."""
message: str
class UnreadCountResponse(BaseModel):
"""Response for unread notification count."""
unread_count: int
message: str | None = None
# ============================================================================
# NOTIFICATION SCHEMAS
# ============================================================================
class NotificationResponse(BaseModel):
"""Single notification response."""
id: int
type: str
title: str
message: str
is_read: bool
read_at: datetime | None = None
priority: str = "normal"
action_url: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class NotificationListResponse(BaseModel):
"""Paginated list of notifications."""
notifications: list[NotificationResponse] = []
total: int = 0
unread_count: int = 0
message: str | None = None
# ============================================================================
# NOTIFICATION SETTINGS SCHEMAS
# ============================================================================
class NotificationSettingsResponse(BaseModel):
"""Notification preferences response."""
email_notifications: bool = True
in_app_notifications: bool = True
notification_types: dict[str, bool] = Field(default_factory=dict)
message: str | None = None
class NotificationSettingsUpdate(BaseModel):
"""Request model for updating notification settings."""
email_notifications: bool | None = None
in_app_notifications: bool | None = None
notification_types: dict[str, bool] | None = None
# ============================================================================
# NOTIFICATION TEMPLATE SCHEMAS
# ============================================================================
class NotificationTemplateResponse(BaseModel):
"""Single notification template response."""
id: int
name: str
type: str
subject: str
body_html: str | None = None
body_text: str | None = None
variables: list[str] = Field(default_factory=list)
is_active: bool = True
created_at: datetime
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class NotificationTemplateListResponse(BaseModel):
"""List of notification templates."""
templates: list[NotificationTemplateResponse] = []
message: str | None = None
class NotificationTemplateUpdate(BaseModel):
"""Request model for updating notification template."""
subject: str | None = Field(None, max_length=200)
body_html: str | None = None
body_text: str | None = None
is_active: bool | None = None
# ============================================================================
# TEST NOTIFICATION SCHEMA
# ============================================================================
class TestNotificationRequest(BaseModel):
"""Request model for sending test notification."""
template_id: int | None = Field(None, description="Template to use")
email: str | None = Field(None, description="Override recipient email")
notification_type: str = Field(
default="test", description="Type of notification to send"
)
# ============================================================================
# ADMIN ALERT STATISTICS SCHEMA
# ============================================================================
class AlertStatisticsResponse(BaseModel):
"""Response for alert statistics."""
total_alerts: int = 0
active_alerts: int = 0
critical_alerts: int = 0
resolved_today: int = 0
__all__ = [
# Response schemas
"MessageResponse",
"UnreadCountResponse",
# Notification schemas
"NotificationResponse",
"NotificationListResponse",
# Settings schemas
"NotificationSettingsResponse",
"NotificationSettingsUpdate",
# Template schemas
"NotificationTemplateResponse",
"NotificationTemplateListResponse",
"NotificationTemplateUpdate",
# Test notification
"TestNotificationRequest",
# Alert statistics
"AlertStatisticsResponse",
]

View File

@@ -1,584 +1,89 @@
# models/schema/order.py
"""
Pydantic schemas for unified order operations.
LEGACY LOCATION - Re-exports from module for backwards compatibility.
Supports both direct orders and marketplace orders (Letzshop, etc.)
with snapshotted customer and address data.
The canonical implementation is now in:
app/modules/orders/schemas/order.py
This file exists to maintain backwards compatibility with code that
imports from the old location. All new code should import directly
from the module:
from app.modules.orders.schemas import order
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ============================================================================
# Address Snapshot Schemas
# ============================================================================
class AddressSnapshot(BaseModel):
"""Address snapshot for order creation."""
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
company: str | None = Field(None, max_length=200)
address_line_1: str = Field(..., min_length=1, max_length=255)
address_line_2: str | None = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country_iso: str = Field(..., min_length=2, max_length=5)
class AddressSnapshotResponse(BaseModel):
"""Address snapshot in order response."""
first_name: str
last_name: str
company: str | None
address_line_1: str
address_line_2: str | None
city: str
postal_code: str
country_iso: str
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
# ============================================================================
# Order Item Schemas
# ============================================================================
class OrderItemCreate(BaseModel):
"""Schema for creating an order item."""
product_id: int
quantity: int = Field(..., ge=1)
class OrderItemExceptionBrief(BaseModel):
"""Brief exception info for embedding in order item responses."""
model_config = ConfigDict(from_attributes=True)
id: int
original_gtin: str | None
original_product_name: str | None
exception_type: str
status: str
resolved_product_id: int | None
class OrderItemResponse(BaseModel):
"""Schema for order item response."""
model_config = ConfigDict(from_attributes=True)
id: int
order_id: int
product_id: int
product_name: str
product_sku: str | None
gtin: str | None
gtin_type: str | None
quantity: int
unit_price: float
total_price: float
# External references (for marketplace items)
external_item_id: str | None = None
external_variant_id: str | None = None
# Item state (for marketplace confirmation flow)
item_state: str | None = None
# Inventory tracking
inventory_reserved: bool
inventory_fulfilled: bool
# Exception tracking
needs_product_match: bool = False
exception: OrderItemExceptionBrief | None = None
created_at: datetime
updated_at: datetime
@property
def is_confirmed(self) -> bool:
"""Check if item has been confirmed (available or unavailable)."""
return self.item_state in ("confirmed_available", "confirmed_unavailable")
@property
def is_available(self) -> bool:
"""Check if item is confirmed as available."""
return self.item_state == "confirmed_available"
@property
def is_declined(self) -> bool:
"""Check if item was declined (unavailable)."""
return self.item_state == "confirmed_unavailable"
@property
def has_unresolved_exception(self) -> bool:
"""Check if item has an unresolved exception blocking confirmation."""
if not self.exception:
return False
return self.exception.status in ("pending", "ignored")
# ============================================================================
# Customer Snapshot Schemas
# ============================================================================
class CustomerSnapshot(BaseModel):
"""Customer snapshot for order creation."""
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
email: str = Field(..., max_length=255)
phone: str | None = Field(None, max_length=50)
locale: str | None = Field(None, max_length=10)
class CustomerSnapshotResponse(BaseModel):
"""Customer snapshot in order response."""
first_name: str
last_name: str
email: str
phone: str | None
locale: str | None
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}".strip()
# ============================================================================
# Order Create/Update Schemas
# ============================================================================
class OrderCreate(BaseModel):
"""Schema for creating an order (direct channel)."""
customer_id: int | None = None # Optional for guest checkout
items: list[OrderItemCreate] = Field(..., min_length=1)
# Customer info snapshot
customer: CustomerSnapshot
# Addresses (snapshots)
shipping_address: AddressSnapshot
billing_address: AddressSnapshot | None = None # Use shipping if not provided
# Optional fields
shipping_method: str | None = None
customer_notes: str | None = Field(None, max_length=1000)
# Cart/session info
session_id: str | None = None
class OrderUpdate(BaseModel):
"""Schema for updating order status."""
status: str | None = Field(
None, pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
)
tracking_number: str | None = None
tracking_provider: str | None = None
internal_notes: str | None = None
class OrderTrackingUpdate(BaseModel):
"""Schema for setting tracking information."""
tracking_number: str = Field(..., min_length=1, max_length=100)
tracking_provider: str = Field(..., min_length=1, max_length=100)
class OrderItemStateUpdate(BaseModel):
"""Schema for updating item state (marketplace confirmation)."""
item_id: int
state: str = Field(..., pattern="^(confirmed_available|confirmed_unavailable)$")
# ============================================================================
# Order Response Schemas
# ============================================================================
class OrderResponse(BaseModel):
"""Schema for order response."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
customer_id: int
order_number: str
# Channel/Source
channel: str
external_order_id: str | None = None
external_shipment_id: str | None = None
external_order_number: str | None = None
# Status
status: str
# Financial
subtotal: float | None
tax_amount: float | None
shipping_amount: float | None
discount_amount: float | None
total_amount: float
currency: str
# VAT information
vat_regime: str | None = None
vat_rate: float | None = None
vat_rate_label: str | None = None
vat_destination_country: str | None = None
# Customer snapshot
customer_first_name: str
customer_last_name: str
customer_email: str
customer_phone: str | None
customer_locale: str | None
# Shipping address snapshot
ship_first_name: str
ship_last_name: str
ship_company: str | None
ship_address_line_1: str
ship_address_line_2: str | None
ship_city: str
ship_postal_code: str
ship_country_iso: str
# Billing address snapshot
bill_first_name: str
bill_last_name: str
bill_company: str | None
bill_address_line_1: str
bill_address_line_2: str | None
bill_city: str
bill_postal_code: str
bill_country_iso: str
# Tracking
shipping_method: str | None
tracking_number: str | None
tracking_provider: str | None
tracking_url: str | None = None
shipment_number: str | None = None
shipping_carrier: str | None = None
# Notes
customer_notes: str | None
internal_notes: str | None
# Timestamps
order_date: datetime
confirmed_at: datetime | None
shipped_at: datetime | None
delivered_at: datetime | None
cancelled_at: datetime | None
created_at: datetime
updated_at: datetime
@property
def customer_full_name(self) -> str:
return f"{self.customer_first_name} {self.customer_last_name}".strip()
@property
def ship_full_name(self) -> str:
return f"{self.ship_first_name} {self.ship_last_name}".strip()
@property
def is_marketplace_order(self) -> bool:
return self.channel != "direct"
class OrderDetailResponse(OrderResponse):
"""Schema for detailed order response with items."""
items: list[OrderItemResponse] = []
# Vendor info (enriched by API)
vendor_name: str | None = None
vendor_code: str | None = None
class OrderListResponse(BaseModel):
"""Schema for paginated order list."""
orders: list[OrderResponse]
total: int
skip: int
limit: int
# ============================================================================
# Order List Item (Simplified for list views)
# ============================================================================
class OrderListItem(BaseModel):
"""Simplified order item for list views."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
order_number: str
channel: str
status: str
# External references
external_order_number: str | None = None
# Customer
customer_full_name: str
customer_email: str
# Financial
total_amount: float
currency: str
# Shipping
ship_country_iso: str
# Tracking
tracking_number: str | None
tracking_provider: str | None
tracking_url: str | None = None
shipment_number: str | None = None
shipping_carrier: str | None = None
# Item count
item_count: int = 0
# Timestamps
order_date: datetime
confirmed_at: datetime | None
shipped_at: datetime | None
# ============================================================================
# Admin Order Schemas
# ============================================================================
class AdminOrderItem(BaseModel):
"""Order item with vendor info for admin list view."""
model_config = ConfigDict(from_attributes=True)
id: int
vendor_id: int
vendor_name: str | None = None
vendor_code: str | None = None
customer_id: int
order_number: str
channel: str
status: str
# External references
external_order_number: str | None = None
external_shipment_id: str | None = None
# Customer snapshot
customer_full_name: str
customer_email: str
# Financial
subtotal: float | None
tax_amount: float | None
shipping_amount: float | None
discount_amount: float | None
total_amount: float
currency: str
# VAT information
vat_regime: str | None = None
vat_rate: float | None = None
vat_rate_label: str | None = None
vat_destination_country: str | None = None
# Shipping
ship_country_iso: str
tracking_number: str | None
tracking_provider: str | None
tracking_url: str | None = None
shipment_number: str | None = None
shipping_carrier: str | None = None
# Item count
item_count: int = 0
# Timestamps
order_date: datetime
confirmed_at: datetime | None
shipped_at: datetime | None
delivered_at: datetime | None
cancelled_at: datetime | None
created_at: datetime
updated_at: datetime
class AdminOrderListResponse(BaseModel):
"""Cross-vendor order list for admin."""
orders: list[AdminOrderItem]
total: int
skip: int
limit: int
class AdminOrderStats(BaseModel):
"""Order statistics for admin dashboard."""
total_orders: int = 0
pending_orders: int = 0
processing_orders: int = 0
shipped_orders: int = 0
delivered_orders: int = 0
cancelled_orders: int = 0
refunded_orders: int = 0
total_revenue: float = 0.0
# By channel
direct_orders: int = 0
letzshop_orders: int = 0
# Vendors
vendors_with_orders: int = 0
class AdminOrderStatusUpdate(BaseModel):
"""Admin version of status update with reason."""
status: str = Field(
..., pattern="^(pending|processing|shipped|delivered|cancelled|refunded)$"
)
tracking_number: str | None = None
tracking_provider: str | None = None
reason: str | None = Field(None, description="Reason for status change")
class AdminVendorWithOrders(BaseModel):
"""Vendor with order count."""
id: int
name: str
vendor_code: str
order_count: int = 0
class AdminVendorsWithOrdersResponse(BaseModel):
"""Response for vendors with orders list."""
vendors: list[AdminVendorWithOrders]
# ============================================================================
# Letzshop-specific Schemas
# ============================================================================
class LetzshopOrderImport(BaseModel):
"""Schema for importing a Letzshop order from shipment data."""
shipment_id: str
order_id: str
order_number: str
order_date: datetime
# Customer
customer_email: str
customer_locale: str | None = None
# Shipping address
ship_first_name: str
ship_last_name: str
ship_company: str | None = None
ship_address_line_1: str
ship_address_line_2: str | None = None
ship_city: str
ship_postal_code: str
ship_country_iso: str
# Billing address
bill_first_name: str
bill_last_name: str
bill_company: str | None = None
bill_address_line_1: str
bill_address_line_2: str | None = None
bill_city: str
bill_postal_code: str
bill_country_iso: str
# Totals
total_amount: float
currency: str = "EUR"
# State
letzshop_state: str # unconfirmed, confirmed, declined
# Items
inventory_units: list[dict]
# Raw data
raw_data: dict | None = None
class LetzshopShippingInfo(BaseModel):
"""Shipping info retrieved from Letzshop."""
tracking_number: str
tracking_provider: str
shipment_id: str
class LetzshopOrderConfirmItem(BaseModel):
"""Schema for confirming/declining a single item."""
item_id: int
external_item_id: str
action: str = Field(..., pattern="^(confirm|decline)$")
class LetzshopOrderConfirmRequest(BaseModel):
"""Schema for confirming/declining order items."""
items: list[LetzshopOrderConfirmItem]
# ============================================================================
# Mark as Shipped Schemas
# ============================================================================
class MarkAsShippedRequest(BaseModel):
"""Schema for marking an order as shipped with tracking info."""
tracking_number: str | None = Field(None, max_length=100)
tracking_url: str | None = Field(None, max_length=500)
shipping_carrier: str | None = Field(None, max_length=50)
class ShippingLabelInfo(BaseModel):
"""Shipping label information for an order."""
shipment_number: str | None = None
shipping_carrier: str | None = None
label_url: str | None = None
tracking_number: str | None = None
tracking_url: str | None = None
from app.modules.orders.schemas.order import (
# Address schemas
AddressSnapshot,
AddressSnapshotResponse,
# Order item schemas
OrderItemCreate,
OrderItemExceptionBrief,
OrderItemResponse,
# Customer schemas
CustomerSnapshot,
CustomerSnapshotResponse,
# Order CRUD schemas
OrderCreate,
OrderUpdate,
OrderTrackingUpdate,
OrderItemStateUpdate,
# Order response schemas
OrderResponse,
OrderDetailResponse,
OrderListResponse,
OrderListItem,
# Admin schemas
AdminOrderItem,
AdminOrderListResponse,
AdminOrderStats,
AdminOrderStatusUpdate,
AdminVendorWithOrders,
AdminVendorsWithOrdersResponse,
# Letzshop schemas
LetzshopOrderImport,
LetzshopShippingInfo,
LetzshopOrderConfirmItem,
LetzshopOrderConfirmRequest,
# Shipping schemas
MarkAsShippedRequest,
ShippingLabelInfo,
)
__all__ = [
# Address schemas
"AddressSnapshot",
"AddressSnapshotResponse",
# Order item schemas
"OrderItemCreate",
"OrderItemExceptionBrief",
"OrderItemResponse",
# Customer schemas
"CustomerSnapshot",
"CustomerSnapshotResponse",
# Order CRUD schemas
"OrderCreate",
"OrderUpdate",
"OrderTrackingUpdate",
"OrderItemStateUpdate",
# Order response schemas
"OrderResponse",
"OrderDetailResponse",
"OrderListResponse",
"OrderListItem",
# Admin schemas
"AdminOrderItem",
"AdminOrderListResponse",
"AdminOrderStats",
"AdminOrderStatusUpdate",
"AdminVendorWithOrders",
"AdminVendorsWithOrdersResponse",
# Letzshop schemas
"LetzshopOrderImport",
"LetzshopShippingInfo",
"LetzshopOrderConfirmItem",
"LetzshopOrderConfirmRequest",
# Shipping schemas
"MarkAsShippedRequest",
"ShippingLabelInfo",
]