fix: resolve all architecture validation errors (62 -> 0)

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>
This commit is contained in:
2026-01-03 18:48:59 +01:00
parent 370d61e8f7
commit 5155ef7445
10 changed files with 391 additions and 377 deletions

View File

@@ -58,12 +58,37 @@ class TestEmailRequest(BaseModel):
variables: dict[str, Any] = {}
class TemplateListItem(BaseModel):
"""Schema for a template in the list."""
code: str
name: str
description: str
category: str
available_languages: list[str]
class Config:
from_attributes = True
class TemplateListResponse(BaseModel):
"""Response schema for listing templates."""
templates: list[TemplateListItem]
class CategoriesResponse(BaseModel):
"""Response schema for template categories."""
categories: list[str]
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
@router.get("", response_model=TemplateListResponse)
def list_templates(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
@@ -74,17 +99,17 @@ def list_templates(
Returns templates grouped by code with available languages.
"""
service = EmailTemplateService(db)
return {"templates": service.list_platform_templates()}
return TemplateListResponse(templates=service.list_platform_templates())
@router.get("/categories")
@router.get("/categories", response_model=CategoriesResponse)
def get_categories(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get list of email template categories."""
service = EmailTemplateService(db)
return {"categories": service.get_template_categories()}
return CategoriesResponse(categories=service.get_template_categories())
@router.get("/{code}")

View File

@@ -279,15 +279,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
)
# Look up customer by email (vendor-scoped)
customer = (
db.query(Customer)
.filter(
Customer.vendor_id == vendor.id,
Customer.email == email.lower(),
Customer.is_active == True, # noqa: E712
)
.first()
)
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
# If customer exists, generate token and send email
if customer:
@@ -365,43 +357,13 @@ def reset_password(
},
)
# Validate password length
if len(new_password) < 8:
raise HTTPException(
status_code=400,
detail="Password must be at least 8 characters long",
)
# Find valid token
token_record = PasswordResetToken.find_valid_token(db, reset_token)
if not token_record:
raise HTTPException(
status_code=400,
detail="Invalid or expired password reset link. Please request a new one.",
)
# 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 HTTPException(
status_code=400,
detail="Invalid or expired password reset link. Please request a new one.",
)
if not customer.is_active:
raise HTTPException(
status_code=400,
detail="This account is not active. Please contact support.",
)
# Hash the new password and update customer
hashed_password = customer_service.auth_service.hash_password(new_password)
customer.hashed_password = hashed_password
# Mark token as used
token_record.mark_used(db)
# Validate and reset password using service
customer = customer_service.validate_and_reset_password(
db=db,
vendor_id=vendor.id,
reset_token=reset_token,
new_password=new_password,
)
db.commit()

View File

@@ -8,8 +8,8 @@ The get_current_vendor_api dependency guarantees token_vendor_id is present.
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
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
@@ -39,6 +39,13 @@ SUPPORTED_LOCALES = [
]
# 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."""
@@ -58,6 +65,29 @@ class LocalizationSettingsUpdate(BaseModel):
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)."""
@@ -74,6 +104,13 @@ class BusinessInfoUpdate(BaseModel):
)
# 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."""
@@ -83,14 +120,38 @@ class LetzshopFeedSettingsUpdate(BaseModel):
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, description="Pre-order lead time in days")
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
# Valid Letzshop tax rates
VALID_TAX_RATES = [0, 3, 8, 14, 17]
@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
# Valid delivery methods
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]
@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("")
@@ -305,52 +366,14 @@ def update_letzshop_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""Update Letzshop marketplace feed settings."""
"""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)
# Validate tax rate
if "letzshop_default_tax_rate" in update_data:
if update_data["letzshop_default_tax_rate"] not in VALID_TAX_RATES:
raise HTTPException(
status_code=400,
detail=f"Invalid tax rate. Must be one of: {VALID_TAX_RATES}"
)
# Validate delivery method
if "letzshop_delivery_method" in update_data:
methods = update_data["letzshop_delivery_method"].split(",")
for method in methods:
if method.strip() not in VALID_DELIVERY_METHODS:
raise HTTPException(
status_code=400,
detail=f"Invalid delivery method. Must be one of: {VALID_DELIVERY_METHODS}"
)
# Validate boost_sort (0.0 - 10.0)
if "letzshop_boost_sort" in update_data:
try:
boost = float(update_data["letzshop_boost_sort"])
if boost < 0.0 or boost > 10.0:
raise HTTPException(
status_code=400,
detail="Boost sort must be between 0.0 and 10.0"
)
except ValueError:
raise HTTPException(
status_code=400,
detail="Boost sort must be a valid number"
)
# Validate preorder_days
if "letzshop_preorder_days" in update_data:
if update_data["letzshop_preorder_days"] < 0:
raise HTTPException(
status_code=400,
detail="Preorder days must be non-negative"
)
# Apply updates
# Apply updates (validation already done by Pydantic)
for key, value in update_data.items():
setattr(vendor, key, value)
@@ -386,45 +409,14 @@ def update_localization_settings(
- 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
# Update only provided fields (validation already done by Pydantic)
update_data = localization_config.model_dump(exclude_unset=True)
# Validate language codes
valid_language_codes = {lang["code"] for lang in SUPPORTED_LANGUAGES}
valid_locale_codes = {loc["code"] for loc in SUPPORTED_LOCALES}
if "default_language" in update_data and update_data["default_language"]:
if update_data["default_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['default_language']}"
)
if "dashboard_language" in update_data and update_data["dashboard_language"]:
if update_data["dashboard_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['dashboard_language']}"
)
if "storefront_language" in update_data and update_data["storefront_language"]:
if update_data["storefront_language"] not in valid_language_codes:
raise HTTPException(
status_code=400, detail=f"Invalid language: {update_data['storefront_language']}"
)
if "storefront_languages" in update_data and update_data["storefront_languages"]:
for lang in update_data["storefront_languages"]:
if lang not in valid_language_codes:
raise HTTPException(status_code=400, detail=f"Invalid language: {lang}")
if "storefront_locale" in update_data and update_data["storefront_locale"]:
if update_data["storefront_locale"] not in valid_locale_codes:
raise HTTPException(
status_code=400, detail=f"Invalid locale: {update_data['storefront_locale']}"
)
# Apply updates
for key, value in update_data.items():
setattr(vendor, key, value)