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:
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
160
app/api/v1/vendor/settings.py
vendored
160
app/api/v1/vendor/settings.py
vendored
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user