Files
orion/app/api/v1/vendor/settings.py
Samir Boulahtit cad862f469 refactor(api): introduce UserContext schema for API dependency injection
Replace direct User database model imports in API endpoints with UserContext
schema, following the architecture principle that API routes should not import
database models directly.

Changes:
- Create UserContext schema in models/schema/auth.py with from_user() factory
- Update app/api/deps.py to return UserContext from all auth dependencies
- Add _get_user_model() helper for functions needing User model access
- Update 58 API endpoint files to use UserContext instead of User
- Add noqa comments for 4 legitimate edge cases (enums, internal helpers)

Architecture validation: 0 errors (down from 61), 11 warnings remain

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:47:33 +01:00

440 lines
17 KiB
Python

# app/api/v1/vendor/settings.py
"""
Vendor settings and configuration endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.platform_settings_service import platform_settings_service
from app.services.vendor_service import vendor_service
from models.schema.auth import UserContext
router = APIRouter(prefix="/settings")
logger = logging.getLogger(__name__)
# Supported languages for dropdown
SUPPORTED_LANGUAGES = [
{"code": "en", "name": "English"},
{"code": "fr", "name": "Français"},
{"code": "de", "name": "Deutsch"},
{"code": "lb", "name": "Lëtzebuergesch"},
]
# Supported locales for currency/number formatting
SUPPORTED_LOCALES = [
{"code": "fr-LU", "name": "Luxembourg (French)", "example": "29,99 €"},
{"code": "de-LU", "name": "Luxembourg (German)", "example": "29,99 €"},
{"code": "de-DE", "name": "Germany", "example": "29,99 €"},
{"code": "fr-FR", "name": "France", "example": "29,99 €"},
{"code": "en-GB", "name": "United Kingdom", "example": "€29.99"},
]
# Valid language codes for validation
VALID_LANGUAGE_CODES = {"en", "fr", "de", "lb"}
# Valid locale codes for validation
VALID_LOCALE_CODES = {"fr-LU", "de-LU", "de-DE", "fr-FR", "en-GB"}
class LocalizationSettingsUpdate(BaseModel):
"""Schema for updating localization settings."""
default_language: str | None = Field(
None, description="Default language for vendor content"
)
dashboard_language: str | None = Field(
None, description="Language for vendor dashboard UI"
)
storefront_language: str | None = Field(
None, description="Default language for customer storefront"
)
storefront_languages: list[str] | None = Field(
None, description="Enabled languages for storefront selector"
)
storefront_locale: str | None = Field(
None, description="Locale for currency/number formatting"
)
@field_validator("default_language", "dashboard_language", "storefront_language")
@classmethod
def validate_language(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_LANGUAGE_CODES:
raise ValueError(f"Invalid language: {v}. Must be one of: {sorted(VALID_LANGUAGE_CODES)}")
return v
@field_validator("storefront_languages")
@classmethod
def validate_storefront_languages(cls, v: list[str] | None) -> list[str] | None:
if v is not None:
for lang in v:
if lang not in VALID_LANGUAGE_CODES:
raise ValueError(f"Invalid language: {lang}. Must be one of: {sorted(VALID_LANGUAGE_CODES)}")
return v
@field_validator("storefront_locale")
@classmethod
def validate_locale(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_LOCALE_CODES:
raise ValueError(f"Invalid locale: {v}. Must be one of: {sorted(VALID_LOCALE_CODES)}")
return v
class BusinessInfoUpdate(BaseModel):
"""Schema for updating business info (can override company values)."""
name: str | None = Field(None, description="Store/brand name")
description: str | None = Field(None, description="Store description")
contact_email: str | None = Field(None, description="Contact email (null = inherit from company)")
contact_phone: str | None = Field(None, description="Contact phone (null = inherit from company)")
website: str | None = Field(None, description="Website URL (null = inherit from company)")
business_address: str | None = Field(None, description="Business address (null = inherit from company)")
tax_number: str | None = Field(None, description="Tax/VAT number (null = inherit from company)")
reset_to_company: list[str] | None = Field(
None, description="List of fields to reset to company values (e.g., ['contact_email', 'website'])"
)
# Valid Letzshop tax rates
VALID_TAX_RATES = [0, 3, 8, 14, 17]
# Valid delivery methods
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]
class LetzshopFeedSettingsUpdate(BaseModel):
"""Schema for updating Letzshop feed settings."""
letzshop_csv_url_fr: str | None = Field(None, description="French CSV feed URL")
letzshop_csv_url_en: str | None = Field(None, description="English CSV feed URL")
letzshop_csv_url_de: str | None = Field(None, description="German CSV feed URL")
letzshop_default_tax_rate: int | None = Field(None, description="Default VAT rate (0, 3, 8, 14, 17)")
letzshop_boost_sort: str | None = Field(None, description="Sort priority (0.0-10.0)")
letzshop_delivery_method: str | None = Field(None, description="Delivery method")
letzshop_preorder_days: int | None = Field(None, ge=0, description="Pre-order lead time in days")
@field_validator("letzshop_default_tax_rate")
@classmethod
def validate_tax_rate(cls, v: int | None) -> int | None:
if v is not None and v not in VALID_TAX_RATES:
raise ValueError(f"Invalid tax rate. Must be one of: {VALID_TAX_RATES}")
return v
@field_validator("letzshop_delivery_method")
@classmethod
def validate_delivery_method(cls, v: str | None) -> str | None:
if v is not None:
methods = v.split(",")
for method in methods:
if method.strip() not in VALID_DELIVERY_METHODS:
raise ValueError(f"Invalid delivery method. Must be one of: {VALID_DELIVERY_METHODS}")
return v
@field_validator("letzshop_boost_sort")
@classmethod
def validate_boost_sort(cls, v: str | None) -> str | None:
if v is not None:
try:
boost = float(v)
if boost < 0.0 or boost > 10.0:
raise ValueError("Boost sort must be between 0.0 and 10.0")
except ValueError as e:
if "could not convert" in str(e).lower():
raise ValueError("Boost sort must be a valid number")
raise
return v
@router.get("")
def get_vendor_settings(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Get comprehensive vendor settings and configuration."""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Get platform defaults for display
platform_config = platform_settings_service.get_storefront_config(db)
# Get business info with inheritance flags
contact_info = vendor.get_contact_info_with_inheritance()
# Get invoice settings if exists
invoice_settings = None
if vendor.invoice_settings:
inv = vendor.invoice_settings
invoice_settings = {
"company_name": inv.company_name,
"company_address": inv.company_address,
"company_city": inv.company_city,
"company_postal_code": inv.company_postal_code,
"company_country": inv.company_country,
"vat_number": inv.vat_number,
"is_vat_registered": inv.is_vat_registered,
"invoice_prefix": inv.invoice_prefix,
"invoice_next_number": inv.invoice_next_number,
"payment_terms": inv.payment_terms,
"bank_name": inv.bank_name,
"bank_iban": inv.bank_iban,
"bank_bic": inv.bank_bic,
"footer_text": inv.footer_text,
"default_vat_rate": inv.default_vat_rate,
}
# Get theme settings if exists
theme_settings = None
if vendor.vendor_theme:
theme = vendor.vendor_theme
theme_settings = {
"theme_name": theme.theme_name,
"colors": theme.colors,
"font_family_heading": theme.font_family_heading,
"font_family_body": theme.font_family_body,
"logo_url": theme.logo_url,
"logo_dark_url": theme.logo_dark_url,
"favicon_url": theme.favicon_url,
"banner_url": theme.banner_url,
"layout_style": theme.layout_style,
"header_style": theme.header_style,
"product_card_style": theme.product_card_style,
"social_links": theme.social_links,
"custom_css": theme.custom_css,
}
# Get domains (read-only)
domains = []
for domain in vendor.domains:
domains.append({
"id": domain.id,
"domain": domain.domain,
"is_primary": domain.is_primary,
"is_active": domain.is_active,
"ssl_status": domain.ssl_status,
"is_verified": domain.is_verified,
})
# Get Stripe info from subscription (read-only, masked)
stripe_info = None
if vendor.subscription and vendor.subscription.stripe_customer_id:
stripe_info = {
"has_stripe_customer": True,
"customer_id_masked": f"cus_***{vendor.subscription.stripe_customer_id[-4:]}",
}
return {
# General info
"vendor_code": vendor.vendor_code,
"subdomain": vendor.subdomain,
"name": vendor.name,
"description": vendor.description,
"is_active": vendor.is_active,
"is_verified": vendor.is_verified,
# Business info with inheritance (values + flags)
"business_info": {
"contact_email": contact_info["contact_email"],
"contact_email_inherited": contact_info["contact_email_inherited"],
"contact_email_override": vendor.contact_email, # Raw override value
"contact_phone": contact_info["contact_phone"],
"contact_phone_inherited": contact_info["contact_phone_inherited"],
"contact_phone_override": vendor.contact_phone,
"website": contact_info["website"],
"website_inherited": contact_info["website_inherited"],
"website_override": vendor.website,
"business_address": contact_info["business_address"],
"business_address_inherited": contact_info["business_address_inherited"],
"business_address_override": vendor.business_address,
"tax_number": contact_info["tax_number"],
"tax_number_inherited": contact_info["tax_number_inherited"],
"tax_number_override": vendor.tax_number,
"company_name": vendor.company.name if vendor.company else None,
},
# Localization settings
"localization": {
"default_language": vendor.default_language,
"dashboard_language": vendor.dashboard_language,
"storefront_language": vendor.storefront_language,
"storefront_languages": vendor.storefront_languages or ["fr", "de", "en"],
"storefront_locale": vendor.storefront_locale,
"platform_default_locale": platform_config["locale"],
"platform_currency": platform_config["currency"],
},
# Letzshop marketplace settings
"letzshop": {
"csv_url_fr": vendor.letzshop_csv_url_fr,
"csv_url_en": vendor.letzshop_csv_url_en,
"csv_url_de": vendor.letzshop_csv_url_de,
"default_tax_rate": vendor.letzshop_default_tax_rate,
"boost_sort": vendor.letzshop_boost_sort,
"delivery_method": vendor.letzshop_delivery_method,
"preorder_days": vendor.letzshop_preorder_days,
"vendor_id": vendor.letzshop_vendor_id,
"vendor_slug": vendor.letzshop_vendor_slug,
"has_credentials": vendor.letzshop_credentials is not None,
"auto_sync_enabled": vendor.letzshop_credentials.auto_sync_enabled if vendor.letzshop_credentials else False,
},
# Invoice settings
"invoice_settings": invoice_settings,
# Theme/branding settings
"theme_settings": theme_settings,
# Domains (read-only)
"domains": domains,
"default_subdomain": f"{vendor.subdomain}.letzshop.lu",
# Stripe info (read-only)
"stripe_info": stripe_info,
# Options for dropdowns
"options": {
"supported_languages": SUPPORTED_LANGUAGES,
"supported_locales": SUPPORTED_LOCALES,
"tax_rates": [
{"value": 0, "label": "0% (Exempt)"},
{"value": 3, "label": "3% (Super-reduced)"},
{"value": 8, "label": "8% (Reduced)"},
{"value": 14, "label": "14% (Intermediate)"},
{"value": 17, "label": "17% (Standard)"},
],
"delivery_methods": [
{"value": "nationwide", "label": "Nationwide (all methods)"},
{"value": "package_delivery", "label": "Package Delivery"},
{"value": "self_collect", "label": "Self Collect"},
],
},
}
@router.put("/business-info")
def update_business_info(
business_info: BusinessInfoUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update vendor business info.
Fields can be set to override company values, or reset to inherit from company.
Use reset_to_company list to reset specific fields to inherit from company.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
update_data = business_info.model_dump(exclude_unset=True)
# Handle reset_to_company - set those fields to None
reset_fields = update_data.pop("reset_to_company", None) or []
for field in reset_fields:
if field in ["contact_email", "contact_phone", "website", "business_address", "tax_number"]:
setattr(vendor, field, None)
logger.info(f"Reset {field} to inherit from company for vendor {vendor.id}")
# Update other fields
for key, value in update_data.items():
if key in ["name", "description", "contact_email", "contact_phone", "website", "business_address", "tax_number"]:
setattr(vendor, key, value)
db.commit()
db.refresh(vendor)
logger.info(f"Business info updated for vendor {vendor.id}")
# Return updated info with inheritance
contact_info = vendor.get_contact_info_with_inheritance()
return {
"message": "Business info updated",
"name": vendor.name,
"description": vendor.description,
"business_info": contact_info,
}
@router.put("/letzshop")
def update_letzshop_settings(
letzshop_config: LetzshopFeedSettingsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update Letzshop marketplace feed settings.
Validation is handled by Pydantic model validators.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
update_data = letzshop_config.model_dump(exclude_unset=True)
# Apply updates (validation already done by Pydantic)
for key, value in update_data.items():
setattr(vendor, key, value)
db.commit()
db.refresh(vendor)
logger.info(f"Letzshop settings updated for vendor {vendor.id}")
return {
"message": "Letzshop settings updated",
"csv_url_fr": vendor.letzshop_csv_url_fr,
"csv_url_en": vendor.letzshop_csv_url_en,
"csv_url_de": vendor.letzshop_csv_url_de,
"default_tax_rate": vendor.letzshop_default_tax_rate,
"boost_sort": vendor.letzshop_boost_sort,
"delivery_method": vendor.letzshop_delivery_method,
"preorder_days": vendor.letzshop_preorder_days,
}
@router.put("/localization")
def update_localization_settings(
localization_config: LocalizationSettingsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update vendor localization settings.
Allows vendors to configure:
- default_language: Default language for vendor content
- dashboard_language: UI language for vendor dashboard
- storefront_language: Default language for customer storefront
- storefront_languages: Enabled languages for storefront selector
- storefront_locale: Locale for currency/number formatting (or null for platform default)
Validation is handled by Pydantic model validators.
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id)
# Update only provided fields (validation already done by Pydantic)
update_data = localization_config.model_dump(exclude_unset=True)
# Apply updates
for key, value in update_data.items():
setattr(vendor, key, value)
db.commit()
db.refresh(vendor)
logger.info(
f"Localization settings updated for vendor {vendor.id}",
extra={"vendor_id": vendor.id, "updated_fields": list(update_data.keys())},
)
return {
"message": "Localization settings updated",
"default_language": vendor.default_language,
"dashboard_language": vendor.dashboard_language,
"storefront_language": vendor.storefront_language,
"storefront_languages": vendor.storefront_languages,
"storefront_locale": vendor.storefront_locale,
}