Major refactoring to achieve zero architecture violations: API Layer: - vendor/settings.py: Move validation to Pydantic field validators (tax rate, delivery method, boost sort, preorder days, languages, locales) - admin/email_templates.py: Add Pydantic response models (TemplateListResponse, CategoriesResponse) - shop/auth.py: Move password reset logic to CustomerService Service Layer: - customer_service.py: Add password reset methods (get_customer_for_password_reset, validate_and_reset_password) Exception Layer: - customer.py: Add InvalidPasswordResetTokenException, PasswordTooShortException Frontend: - admin/email-templates.js: Use apiClient, Utils.showToast() - vendor/email-templates.js: Use apiClient, parent init pattern Templates: - admin/email-templates.html: Fix block name to extra_scripts - shop/base.html: Add language default filter Tooling: - validate_architecture.py: Fix LANG-001 false positive for SUPPORTED_LANGUAGES and SUPPORTED_LOCALES blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
440 lines
17 KiB
Python
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.database.user import User
|
|
|
|
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: User = 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: User = 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: User = 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: User = 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,
|
|
}
|