refactor: migrate modules from re-exports to canonical implementations

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

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

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

View File

@@ -2,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,
)

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

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

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