refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,23 @@
|
||||
"""
|
||||
Customers module database models.
|
||||
|
||||
Re-exports customer-related models from their source locations.
|
||||
This is the canonical location for customer models. Module models are
|
||||
automatically discovered and registered with SQLAlchemy's Base.metadata
|
||||
at startup.
|
||||
|
||||
Usage:
|
||||
from app.modules.customers.models import (
|
||||
Customer,
|
||||
CustomerAddress,
|
||||
PasswordResetToken,
|
||||
)
|
||||
"""
|
||||
|
||||
from models.database.customer import (
|
||||
from app.modules.customers.models.customer import (
|
||||
Customer,
|
||||
CustomerAddress,
|
||||
)
|
||||
from models.database.password_reset_token import PasswordResetToken
|
||||
from app.modules.customers.models.password_reset_token import PasswordResetToken
|
||||
|
||||
__all__ = [
|
||||
"Customer",
|
||||
|
||||
93
app/modules/customers/models/customer.py
Normal file
93
app/modules/customers/models/customer.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# app/modules/customers/models/customer.py
|
||||
"""
|
||||
Customer database models.
|
||||
|
||||
Provides Customer and CustomerAddress models for vendor-scoped
|
||||
customer management.
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Customer(Base, TimestampMixin):
|
||||
"""Customer model with vendor isolation."""
|
||||
|
||||
__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):
|
||||
"""Customer address model for shipping and billing addresses."""
|
||||
|
||||
__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}')>"
|
||||
91
app/modules/customers/models/password_reset_token.py
Normal file
91
app/modules/customers/models/password_reset_token.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# app/modules/customers/models/password_reset_token.py
|
||||
"""
|
||||
Password reset token model for customer accounts.
|
||||
|
||||
Security features:
|
||||
- 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)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Session, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
"""Password reset token for customer accounts."""
|
||||
|
||||
__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()
|
||||
@@ -2,27 +2,68 @@
|
||||
"""
|
||||
Customers module Pydantic schemas.
|
||||
|
||||
Re-exports customer-related schemas from their source locations.
|
||||
This is the canonical location for customer schemas.
|
||||
|
||||
Usage:
|
||||
from app.modules.customers.schemas import (
|
||||
CustomerRegister,
|
||||
CustomerUpdate,
|
||||
CustomerResponse,
|
||||
)
|
||||
"""
|
||||
|
||||
from models.schema.customer import (
|
||||
from app.modules.customers.schemas.customer import (
|
||||
# Registration & Authentication
|
||||
CustomerRegister,
|
||||
CustomerUpdate,
|
||||
CustomerLogin,
|
||||
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",
|
||||
"CustomerLogin",
|
||||
"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",
|
||||
]
|
||||
|
||||
340
app/modules/customers/schemas/customer.py
Normal file
340
app/modules/customers/schemas/customer.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# app/modules/customers/schemas/customer.py
|
||||
"""
|
||||
Pydantic schemas for customer-related operations.
|
||||
|
||||
Provides schemas for:
|
||||
- Customer registration and authentication
|
||||
- Customer profile management
|
||||
- Customer addresses
|
||||
- Admin customer management
|
||||
"""
|
||||
|
||||
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 AdminCustomerListResponse(BaseModel):
|
||||
"""Admin paginated customer list with skip/limit."""
|
||||
|
||||
customers: list[AdminCustomerItem] = []
|
||||
total: int = 0
|
||||
skip: int = 0
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class AdminCustomerDetailResponse(AdminCustomerItem):
|
||||
"""Detailed customer response for admin."""
|
||||
|
||||
pass
|
||||
@@ -2,18 +2,25 @@
|
||||
"""
|
||||
Customers module services.
|
||||
|
||||
Re-exports customer-related services from their source locations.
|
||||
This is the canonical location for customer services.
|
||||
|
||||
Usage:
|
||||
from app.modules.customers.services import (
|
||||
customer_service,
|
||||
admin_customer_service,
|
||||
customer_address_service,
|
||||
)
|
||||
"""
|
||||
|
||||
from app.services.customer_service import (
|
||||
from app.modules.customers.services.customer_service import (
|
||||
customer_service,
|
||||
CustomerService,
|
||||
)
|
||||
from app.services.admin_customer_service import (
|
||||
from app.modules.customers.services.admin_customer_service import (
|
||||
admin_customer_service,
|
||||
AdminCustomerService,
|
||||
)
|
||||
from app.services.customer_address_service import (
|
||||
from app.modules.customers.services.customer_address_service import (
|
||||
customer_address_service,
|
||||
CustomerAddressService,
|
||||
)
|
||||
|
||||
242
app/modules/customers/services/admin_customer_service.py
Normal file
242
app/modules/customers/services/admin_customer_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
# app/modules/customers/services/admin_customer_service.py
|
||||
"""
|
||||
Admin customer management service.
|
||||
|
||||
Handles customer operations for admin users across all vendors.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.customer import CustomerNotFoundException
|
||||
from app.modules.customers.models import Customer
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminCustomerService:
|
||||
"""Service for admin-level customer management across vendors."""
|
||||
|
||||
def list_customers(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
search: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
Get paginated list of customers across all vendors.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor ID filter
|
||||
search: Search by email, name, or customer number
|
||||
is_active: Filter by active status
|
||||
skip: Number of records to skip
|
||||
limit: Maximum records to return
|
||||
|
||||
Returns:
|
||||
Tuple of (customers list, total count)
|
||||
"""
|
||||
# Build query
|
||||
query = db.query(Customer).join(Vendor, Customer.vendor_id == Vendor.id)
|
||||
|
||||
# Apply filters
|
||||
if vendor_id:
|
||||
query = query.filter(Customer.vendor_id == vendor_id)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Customer.email.ilike(search_term))
|
||||
| (Customer.first_name.ilike(search_term))
|
||||
| (Customer.last_name.ilike(search_term))
|
||||
| (Customer.customer_number.ilike(search_term))
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Customer.is_active == is_active)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Get paginated results with vendor info
|
||||
customers = (
|
||||
query.add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code)
|
||||
.order_by(Customer.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Format response
|
||||
result = []
|
||||
for row in customers:
|
||||
customer = row[0]
|
||||
vendor_name = row[1]
|
||||
vendor_code = row[2]
|
||||
|
||||
customer_dict = {
|
||||
"id": customer.id,
|
||||
"vendor_id": customer.vendor_id,
|
||||
"email": customer.email,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
"phone": customer.phone,
|
||||
"customer_number": customer.customer_number,
|
||||
"marketing_consent": customer.marketing_consent,
|
||||
"preferred_language": customer.preferred_language,
|
||||
"last_order_date": customer.last_order_date,
|
||||
"total_orders": customer.total_orders,
|
||||
"total_spent": float(customer.total_spent) if customer.total_spent else 0,
|
||||
"is_active": customer.is_active,
|
||||
"created_at": customer.created_at,
|
||||
"updated_at": customer.updated_at,
|
||||
"vendor_name": vendor_name,
|
||||
"vendor_code": vendor_code,
|
||||
}
|
||||
result.append(customer_dict)
|
||||
|
||||
return result, total
|
||||
|
||||
def get_customer_stats(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get customer statistics.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor ID filter
|
||||
|
||||
Returns:
|
||||
Dict with customer statistics
|
||||
"""
|
||||
query = db.query(Customer)
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(Customer.vendor_id == vendor_id)
|
||||
|
||||
total = query.count()
|
||||
active = query.filter(Customer.is_active == True).count() # noqa: E712
|
||||
inactive = query.filter(Customer.is_active == False).count() # noqa: E712
|
||||
with_orders = query.filter(Customer.total_orders > 0).count()
|
||||
|
||||
# Total spent across all customers
|
||||
total_spent_result = query.with_entities(func.sum(Customer.total_spent)).scalar()
|
||||
total_spent = float(total_spent_result) if total_spent_result else 0
|
||||
|
||||
# Average order value
|
||||
total_orders_result = query.with_entities(func.sum(Customer.total_orders)).scalar()
|
||||
total_orders = int(total_orders_result) if total_orders_result else 0
|
||||
avg_order_value = total_spent / total_orders if total_orders > 0 else 0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"with_orders": with_orders,
|
||||
"total_spent": total_spent,
|
||||
"total_orders": total_orders,
|
||||
"avg_order_value": round(avg_order_value, 2),
|
||||
}
|
||||
|
||||
def get_customer(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get customer details by ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Customer dict with vendor info
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
"""
|
||||
result = (
|
||||
db.query(Customer)
|
||||
.join(Vendor, Customer.vendor_id == Vendor.id)
|
||||
.add_columns(Vendor.name.label("vendor_name"), Vendor.vendor_code)
|
||||
.filter(Customer.id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise CustomerNotFoundException(str(customer_id))
|
||||
|
||||
customer = result[0]
|
||||
return {
|
||||
"id": customer.id,
|
||||
"vendor_id": customer.vendor_id,
|
||||
"email": customer.email,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
"phone": customer.phone,
|
||||
"customer_number": customer.customer_number,
|
||||
"marketing_consent": customer.marketing_consent,
|
||||
"preferred_language": customer.preferred_language,
|
||||
"last_order_date": customer.last_order_date,
|
||||
"total_orders": customer.total_orders,
|
||||
"total_spent": float(customer.total_spent) if customer.total_spent else 0,
|
||||
"is_active": customer.is_active,
|
||||
"created_at": customer.created_at,
|
||||
"updated_at": customer.updated_at,
|
||||
"vendor_name": result[1],
|
||||
"vendor_code": result[2],
|
||||
}
|
||||
|
||||
def toggle_customer_status(
|
||||
self,
|
||||
db: Session,
|
||||
customer_id: int,
|
||||
admin_email: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Toggle customer active status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
admin_email: Admin user email for logging
|
||||
|
||||
Returns:
|
||||
Dict with customer ID, new status, and message
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
"""
|
||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
|
||||
if not customer:
|
||||
raise CustomerNotFoundException(str(customer_id))
|
||||
|
||||
customer.is_active = not customer.is_active
|
||||
db.flush()
|
||||
db.refresh(customer)
|
||||
|
||||
status = "activated" if customer.is_active else "deactivated"
|
||||
logger.info(f"Customer {customer.email} {status} by admin {admin_email}")
|
||||
|
||||
return {
|
||||
"id": customer.id,
|
||||
"is_active": customer.is_active,
|
||||
"message": f"Customer {status} successfully",
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
admin_customer_service = AdminCustomerService()
|
||||
314
app/modules/customers/services/customer_address_service.py
Normal file
314
app/modules/customers/services/customer_address_service.py
Normal file
@@ -0,0 +1,314 @@
|
||||
# app/modules/customers/services/customer_address_service.py
|
||||
"""
|
||||
Customer Address Service
|
||||
|
||||
Business logic for managing customer addresses with vendor isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
AddressLimitExceededException,
|
||||
AddressNotFoundException,
|
||||
)
|
||||
from app.modules.customers.models import CustomerAddress
|
||||
from app.modules.customers.schemas import CustomerAddressCreate, CustomerAddressUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomerAddressService:
|
||||
"""Service for managing customer addresses with vendor isolation."""
|
||||
|
||||
MAX_ADDRESSES_PER_CUSTOMER = 10
|
||||
|
||||
def list_addresses(
|
||||
self, db: Session, vendor_id: int, customer_id: int
|
||||
) -> list[CustomerAddress]:
|
||||
"""
|
||||
Get all addresses for a customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
List of customer addresses
|
||||
"""
|
||||
return (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.order_by(CustomerAddress.is_default.desc(), CustomerAddress.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Get a specific address with ownership validation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Returns:
|
||||
Customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found or doesn't belong to customer
|
||||
"""
|
||||
address = (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.id == address_id,
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not address:
|
||||
raise AddressNotFoundException(address_id)
|
||||
|
||||
return address
|
||||
|
||||
def get_default_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_type: str
|
||||
) -> CustomerAddress | None:
|
||||
"""
|
||||
Get the default address for a specific type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_type: 'shipping' or 'billing'
|
||||
|
||||
Returns:
|
||||
Default address or None if not set
|
||||
"""
|
||||
return (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
CustomerAddress.address_type == address_type,
|
||||
CustomerAddress.is_default == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def create_address(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_data: CustomerAddressCreate,
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Create a new address for a customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_data: Address creation data
|
||||
|
||||
Returns:
|
||||
Created customer address
|
||||
|
||||
Raises:
|
||||
AddressLimitExceededException: If max addresses reached
|
||||
"""
|
||||
# Check address limit
|
||||
current_count = (
|
||||
db.query(CustomerAddress)
|
||||
.filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
if current_count >= self.MAX_ADDRESSES_PER_CUSTOMER:
|
||||
raise AddressLimitExceededException(self.MAX_ADDRESSES_PER_CUSTOMER)
|
||||
|
||||
# If setting as default, clear other defaults of same type
|
||||
if address_data.is_default:
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address_data.address_type
|
||||
)
|
||||
|
||||
# Create the address
|
||||
address = CustomerAddress(
|
||||
vendor_id=vendor_id,
|
||||
customer_id=customer_id,
|
||||
address_type=address_data.address_type,
|
||||
first_name=address_data.first_name,
|
||||
last_name=address_data.last_name,
|
||||
company=address_data.company,
|
||||
address_line_1=address_data.address_line_1,
|
||||
address_line_2=address_data.address_line_2,
|
||||
city=address_data.city,
|
||||
postal_code=address_data.postal_code,
|
||||
country_name=address_data.country_name,
|
||||
country_iso=address_data.country_iso,
|
||||
is_default=address_data.is_default,
|
||||
)
|
||||
|
||||
db.add(address)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Created address {address.id} for customer {customer_id} "
|
||||
f"(type={address_data.address_type}, default={address_data.is_default})"
|
||||
)
|
||||
|
||||
return address
|
||||
|
||||
def update_address(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_id: int,
|
||||
address_data: CustomerAddressUpdate,
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Update an existing address.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
address_data: Address update data
|
||||
|
||||
Returns:
|
||||
Updated customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = address_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle default flag - clear others if setting to default
|
||||
if update_data.get("is_default") is True:
|
||||
# Use updated type if provided, otherwise current type
|
||||
address_type = update_data.get("address_type", address.address_type)
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address_type, exclude_id=address_id
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(address, field, value)
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Updated address {address_id} for customer {customer_id}")
|
||||
|
||||
return address
|
||||
|
||||
def delete_address(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> None:
|
||||
"""
|
||||
Delete an address.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
db.delete(address)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Deleted address {address_id} for customer {customer_id}")
|
||||
|
||||
def set_default(
|
||||
self, db: Session, vendor_id: int, customer_id: int, address_id: int
|
||||
) -> CustomerAddress:
|
||||
"""
|
||||
Set an address as the default for its type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_id: Address ID
|
||||
|
||||
Returns:
|
||||
Updated customer address
|
||||
|
||||
Raises:
|
||||
AddressNotFoundException: If address not found
|
||||
"""
|
||||
address = self.get_address(db, vendor_id, customer_id, address_id)
|
||||
|
||||
# Clear other defaults of same type
|
||||
self._clear_other_defaults(
|
||||
db, vendor_id, customer_id, address.address_type, exclude_id=address_id
|
||||
)
|
||||
|
||||
# Set this one as default
|
||||
address.is_default = True
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Set address {address_id} as default {address.address_type} "
|
||||
f"for customer {customer_id}"
|
||||
)
|
||||
|
||||
return address
|
||||
|
||||
def _clear_other_defaults(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
address_type: str,
|
||||
exclude_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Clear the default flag on other addresses of the same type.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID for isolation
|
||||
customer_id: Customer ID
|
||||
address_type: 'shipping' or 'billing'
|
||||
exclude_id: Address ID to exclude from clearing
|
||||
"""
|
||||
query = db.query(CustomerAddress).filter(
|
||||
CustomerAddress.vendor_id == vendor_id,
|
||||
CustomerAddress.customer_id == customer_id,
|
||||
CustomerAddress.address_type == address_type,
|
||||
CustomerAddress.is_default == True, # noqa: E712
|
||||
)
|
||||
|
||||
if exclude_id:
|
||||
query = query.filter(CustomerAddress.id != exclude_id)
|
||||
|
||||
query.update({"is_default": False}, synchronize_session=False)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
customer_address_service = CustomerAddressService()
|
||||
659
app/modules/customers/services/customer_service.py
Normal file
659
app/modules/customers/services/customer_service.py
Normal file
@@ -0,0 +1,659 @@
|
||||
# app/modules/customers/services/customer_service.py
|
||||
"""
|
||||
Customer management service.
|
||||
|
||||
Handles customer registration, authentication, and profile management
|
||||
with complete vendor isolation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions.customer import (
|
||||
CustomerNotActiveException,
|
||||
CustomerNotFoundException,
|
||||
CustomerValidationException,
|
||||
DuplicateCustomerEmailException,
|
||||
InvalidCustomerCredentialsException,
|
||||
InvalidPasswordResetTokenException,
|
||||
PasswordTooShortException,
|
||||
)
|
||||
from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException
|
||||
from app.services.auth_service import AuthService
|
||||
from app.modules.customers.models import Customer, PasswordResetToken
|
||||
from app.modules.customers.schemas import CustomerRegister, CustomerUpdate
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomerService:
|
||||
"""Service for managing vendor-scoped customers."""
|
||||
|
||||
def __init__(self):
|
||||
self.auth_service = AuthService()
|
||||
|
||||
def register_customer(
|
||||
self, db: Session, vendor_id: int, customer_data: CustomerRegister
|
||||
) -> Customer:
|
||||
"""
|
||||
Register a new customer for a specific vendor.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_data: Customer registration data
|
||||
|
||||
Returns:
|
||||
Customer: Created customer object
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor doesn't exist
|
||||
VendorNotActiveException: If vendor is not active
|
||||
DuplicateCustomerEmailException: If email already exists for this vendor
|
||||
CustomerValidationException: If customer data is invalid
|
||||
"""
|
||||
# Verify vendor exists and is active
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
||||
|
||||
if not vendor.is_active:
|
||||
raise VendorNotActiveException(vendor.vendor_code)
|
||||
|
||||
# Check if email already exists for this vendor
|
||||
existing_customer = (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
and_(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.email == customer_data.email.lower(),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_customer:
|
||||
raise DuplicateCustomerEmailException(
|
||||
customer_data.email, vendor.vendor_code
|
||||
)
|
||||
|
||||
# Generate unique customer number for this vendor
|
||||
customer_number = self._generate_customer_number(
|
||||
db, vendor_id, vendor.vendor_code
|
||||
)
|
||||
|
||||
# Hash password
|
||||
hashed_password = self.auth_service.hash_password(customer_data.password)
|
||||
|
||||
# Create customer
|
||||
customer = Customer(
|
||||
vendor_id=vendor_id,
|
||||
email=customer_data.email.lower(),
|
||||
hashed_password=hashed_password,
|
||||
first_name=customer_data.first_name,
|
||||
last_name=customer_data.last_name,
|
||||
phone=customer_data.phone,
|
||||
customer_number=customer_number,
|
||||
marketing_consent=(
|
||||
customer_data.marketing_consent
|
||||
if hasattr(customer_data, "marketing_consent")
|
||||
else False
|
||||
),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
db.refresh(customer)
|
||||
|
||||
logger.info(
|
||||
f"Customer registered successfully: {customer.email} "
|
||||
f"(ID: {customer.id}, Number: {customer.customer_number}) "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
)
|
||||
|
||||
return customer
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering customer: {str(e)}")
|
||||
raise CustomerValidationException(
|
||||
message="Failed to register customer", details={"error": str(e)}
|
||||
)
|
||||
|
||||
def login_customer(
|
||||
self, db: Session, vendor_id: int, credentials
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Authenticate customer and generate JWT token.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
credentials: Login credentials (UserLogin schema)
|
||||
|
||||
Returns:
|
||||
Dict containing customer and token data
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException: If vendor doesn't exist
|
||||
InvalidCustomerCredentialsException: If credentials are invalid
|
||||
CustomerNotActiveException: If customer account is inactive
|
||||
"""
|
||||
# Verify vendor exists
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
||||
|
||||
# Find customer by email (vendor-scoped)
|
||||
customer = (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
and_(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.email == credentials.email_or_username.lower(),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not customer:
|
||||
raise InvalidCustomerCredentialsException()
|
||||
|
||||
# Verify password using auth_manager directly
|
||||
if not self.auth_service.auth_manager.verify_password(
|
||||
credentials.password, customer.hashed_password
|
||||
):
|
||||
raise InvalidCustomerCredentialsException()
|
||||
|
||||
# Check if customer is active
|
||||
if not customer.is_active:
|
||||
raise CustomerNotActiveException(customer.email)
|
||||
|
||||
# Generate JWT token with customer context
|
||||
from jose import jwt
|
||||
|
||||
auth_manager = self.auth_service.auth_manager
|
||||
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
|
||||
payload = {
|
||||
"sub": str(customer.id),
|
||||
"email": customer.email,
|
||||
"vendor_id": vendor_id,
|
||||
"type": "customer",
|
||||
"exp": expire,
|
||||
"iat": datetime.now(UTC),
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
payload, auth_manager.secret_key, algorithm=auth_manager.algorithm
|
||||
)
|
||||
|
||||
token_data = {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": auth_manager.token_expire_minutes * 60,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Customer login successful: {customer.email} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
)
|
||||
|
||||
return {"customer": customer, "token_data": token_data}
|
||||
|
||||
def get_customer(self, db: Session, vendor_id: int, customer_id: int) -> Customer:
|
||||
"""
|
||||
Get customer by ID with vendor isolation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Customer: Customer object
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
"""
|
||||
customer = (
|
||||
db.query(Customer)
|
||||
.filter(and_(Customer.id == customer_id, Customer.vendor_id == vendor_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
if not customer:
|
||||
raise CustomerNotFoundException(str(customer_id))
|
||||
|
||||
return customer
|
||||
|
||||
def get_customer_by_email(
|
||||
self, db: Session, vendor_id: int, email: str
|
||||
) -> Customer | None:
|
||||
"""
|
||||
Get customer by email (vendor-scoped).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
email: Customer email
|
||||
|
||||
Returns:
|
||||
Optional[Customer]: Customer object or None
|
||||
"""
|
||||
return (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
and_(Customer.vendor_id == vendor_id, Customer.email == email.lower())
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_vendor_customers(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> tuple[list[Customer], int]:
|
||||
"""
|
||||
Get all customers for a vendor with filtering and pagination.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
search: Search in name/email
|
||||
is_active: Filter by active status
|
||||
|
||||
Returns:
|
||||
Tuple of (customers, total_count)
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
query = db.query(Customer).filter(Customer.vendor_id == vendor_id)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Customer.email.ilike(search_pattern),
|
||||
Customer.first_name.ilike(search_pattern),
|
||||
Customer.last_name.ilike(search_pattern),
|
||||
Customer.customer_number.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Customer.is_active == is_active)
|
||||
|
||||
# Order by most recent first
|
||||
query = query.order_by(Customer.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
customers = query.offset(skip).limit(limit).all()
|
||||
|
||||
return customers, total
|
||||
|
||||
def get_customer_orders(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list, int]:
|
||||
"""
|
||||
Get orders for a specific customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
skip: Pagination offset
|
||||
limit: Pagination limit
|
||||
|
||||
Returns:
|
||||
Tuple of (orders, total_count)
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
"""
|
||||
from models.database.order import Order
|
||||
|
||||
# Verify customer belongs to vendor
|
||||
self.get_customer(db, vendor_id, customer_id)
|
||||
|
||||
# Get customer orders
|
||||
query = (
|
||||
db.query(Order)
|
||||
.filter(
|
||||
Order.customer_id == customer_id,
|
||||
Order.vendor_id == vendor_id,
|
||||
)
|
||||
.order_by(Order.created_at.desc())
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
orders = query.offset(skip).limit(limit).all()
|
||||
|
||||
return orders, total
|
||||
|
||||
def get_customer_statistics(
|
||||
self, db: Session, vendor_id: int, customer_id: int
|
||||
) -> dict:
|
||||
"""
|
||||
Get detailed statistics for a customer.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Dict with customer statistics
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
from models.database.order import Order
|
||||
|
||||
customer = self.get_customer(db, vendor_id, customer_id)
|
||||
|
||||
# Get order statistics
|
||||
order_stats = (
|
||||
db.query(
|
||||
func.count(Order.id).label("total_orders"),
|
||||
func.sum(Order.total_cents).label("total_spent_cents"),
|
||||
func.avg(Order.total_cents).label("avg_order_cents"),
|
||||
func.max(Order.created_at).label("last_order_date"),
|
||||
)
|
||||
.filter(Order.customer_id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
total_orders = order_stats.total_orders or 0
|
||||
total_spent_cents = order_stats.total_spent_cents or 0
|
||||
avg_order_cents = order_stats.avg_order_cents or 0
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"total_orders": total_orders,
|
||||
"total_spent": total_spent_cents / 100, # Convert to euros
|
||||
"average_order_value": avg_order_cents / 100 if avg_order_cents else 0.0,
|
||||
"last_order_date": order_stats.last_order_date,
|
||||
"member_since": customer.created_at,
|
||||
"is_active": customer.is_active,
|
||||
}
|
||||
|
||||
def toggle_customer_status(
|
||||
self, db: Session, vendor_id: int, customer_id: int
|
||||
) -> Customer:
|
||||
"""
|
||||
Toggle customer active status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Customer: Updated customer
|
||||
"""
|
||||
customer = self.get_customer(db, vendor_id, customer_id)
|
||||
customer.is_active = not customer.is_active
|
||||
|
||||
db.flush()
|
||||
db.refresh(customer)
|
||||
|
||||
action = "activated" if customer.is_active else "deactivated"
|
||||
logger.info(f"Customer {action}: {customer.email} (ID: {customer.id})")
|
||||
|
||||
return customer
|
||||
|
||||
def update_customer(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
customer_id: int,
|
||||
customer_data: CustomerUpdate,
|
||||
) -> Customer:
|
||||
"""
|
||||
Update customer profile.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
customer_data: Updated customer data
|
||||
|
||||
Returns:
|
||||
Customer: Updated customer object
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
CustomerValidationException: If update data is invalid
|
||||
"""
|
||||
customer = self.get_customer(db, vendor_id, customer_id)
|
||||
|
||||
# Update fields
|
||||
update_data = customer_data.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == "email" and value:
|
||||
# Check if new email already exists for this vendor
|
||||
existing = (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
and_(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.email == value.lower(),
|
||||
Customer.id != customer_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise DuplicateCustomerEmailException(value, "vendor")
|
||||
|
||||
setattr(customer, field, value.lower())
|
||||
elif hasattr(customer, field):
|
||||
setattr(customer, field, value)
|
||||
|
||||
try:
|
||||
db.flush()
|
||||
db.refresh(customer)
|
||||
|
||||
logger.info(f"Customer updated: {customer.email} (ID: {customer.id})")
|
||||
|
||||
return customer
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating customer: {str(e)}")
|
||||
raise CustomerValidationException(
|
||||
message="Failed to update customer", details={"error": str(e)}
|
||||
)
|
||||
|
||||
def deactivate_customer(
|
||||
self, db: Session, vendor_id: int, customer_id: int
|
||||
) -> Customer:
|
||||
"""
|
||||
Deactivate customer account.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
customer_id: Customer ID
|
||||
|
||||
Returns:
|
||||
Customer: Deactivated customer object
|
||||
|
||||
Raises:
|
||||
CustomerNotFoundException: If customer not found
|
||||
"""
|
||||
customer = self.get_customer(db, vendor_id, customer_id)
|
||||
customer.is_active = False
|
||||
|
||||
db.flush()
|
||||
db.refresh(customer)
|
||||
|
||||
logger.info(f"Customer deactivated: {customer.email} (ID: {customer.id})")
|
||||
|
||||
return customer
|
||||
|
||||
def update_customer_stats(
|
||||
self, db: Session, customer_id: int, order_total: float
|
||||
) -> None:
|
||||
"""
|
||||
Update customer statistics after order.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
customer_id: Customer ID
|
||||
order_total: Order total amount
|
||||
"""
|
||||
customer = db.query(Customer).filter(Customer.id == customer_id).first()
|
||||
|
||||
if customer:
|
||||
customer.total_orders += 1
|
||||
customer.total_spent += order_total
|
||||
customer.last_order_date = datetime.utcnow()
|
||||
|
||||
logger.debug(f"Updated stats for customer {customer.email}")
|
||||
|
||||
def _generate_customer_number(
|
||||
self, db: Session, vendor_id: int, vendor_code: str
|
||||
) -> str:
|
||||
"""
|
||||
Generate unique customer number for vendor.
|
||||
|
||||
Format: {VENDOR_CODE}-CUST-{SEQUENCE}
|
||||
Example: VENDORA-CUST-00001
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
vendor_code: Vendor code
|
||||
|
||||
Returns:
|
||||
str: Unique customer number
|
||||
"""
|
||||
# Get count of customers for this vendor
|
||||
count = db.query(Customer).filter(Customer.vendor_id == vendor_id).count()
|
||||
|
||||
# Generate number with padding
|
||||
sequence = str(count + 1).zfill(5)
|
||||
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
|
||||
|
||||
# Ensure uniqueness (in case of deletions)
|
||||
while (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
and_(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.customer_number == customer_number,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
):
|
||||
count += 1
|
||||
sequence = str(count + 1).zfill(5)
|
||||
customer_number = f"{vendor_code.upper()}-CUST-{sequence}"
|
||||
|
||||
return customer_number
|
||||
|
||||
def get_customer_for_password_reset(
|
||||
self, db: Session, vendor_id: int, email: str
|
||||
) -> Customer | None:
|
||||
"""
|
||||
Get active customer by email for password reset.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
email: Customer email
|
||||
|
||||
Returns:
|
||||
Customer if found and active, None otherwise
|
||||
"""
|
||||
return (
|
||||
db.query(Customer)
|
||||
.filter(
|
||||
Customer.vendor_id == vendor_id,
|
||||
Customer.email == email.lower(),
|
||||
Customer.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def validate_and_reset_password(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
reset_token: str,
|
||||
new_password: str,
|
||||
) -> Customer:
|
||||
"""
|
||||
Validate reset token and update customer password.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Vendor ID
|
||||
reset_token: Password reset token from email
|
||||
new_password: New password
|
||||
|
||||
Returns:
|
||||
Customer: Updated customer
|
||||
|
||||
Raises:
|
||||
PasswordTooShortException: If password too short
|
||||
InvalidPasswordResetTokenException: If token invalid/expired
|
||||
CustomerNotActiveException: If customer not active
|
||||
"""
|
||||
# Validate password length
|
||||
if len(new_password) < 8:
|
||||
raise PasswordTooShortException(min_length=8)
|
||||
|
||||
# Find valid token
|
||||
token_record = PasswordResetToken.find_valid_token(db, reset_token)
|
||||
|
||||
if not token_record:
|
||||
raise InvalidPasswordResetTokenException()
|
||||
|
||||
# Get the customer and verify they belong to this vendor
|
||||
customer = (
|
||||
db.query(Customer)
|
||||
.filter(Customer.id == token_record.customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not customer or customer.vendor_id != vendor_id:
|
||||
raise InvalidPasswordResetTokenException()
|
||||
|
||||
if not customer.is_active:
|
||||
raise CustomerNotActiveException(customer.email)
|
||||
|
||||
# Hash the new password and update customer
|
||||
hashed_password = self.auth_service.hash_password(new_password)
|
||||
customer.hashed_password = hashed_password
|
||||
|
||||
# Mark token as used
|
||||
token_record.mark_used(db)
|
||||
|
||||
logger.info(f"Password reset completed for customer {customer.id}")
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
# Singleton instance
|
||||
customer_service = CustomerService()
|
||||
Reference in New Issue
Block a user