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)
|
||||
|
||||
@@ -91,3 +91,26 @@ class CustomerAuthorizationException(BusinessLogicException):
|
||||
error_code="CUSTOMER_NOT_AUTHORIZED",
|
||||
details={"customer_email": customer_email, "operation": operation},
|
||||
)
|
||||
|
||||
|
||||
class InvalidPasswordResetTokenException(ValidationException):
|
||||
"""Raised when password reset token is invalid or expired."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Invalid or expired password reset link. Please request a new one.",
|
||||
field="reset_token",
|
||||
)
|
||||
self.error_code = "INVALID_RESET_TOKEN"
|
||||
|
||||
|
||||
class PasswordTooShortException(ValidationException):
|
||||
"""Raised when password doesn't meet minimum length requirement."""
|
||||
|
||||
def __init__(self, min_length: int = 8):
|
||||
super().__init__(
|
||||
message=f"Password must be at least {min_length} characters long",
|
||||
field="password",
|
||||
details={"min_length": min_length},
|
||||
)
|
||||
self.error_code = "PASSWORD_TOO_SHORT"
|
||||
|
||||
@@ -19,10 +19,13 @@ from app.exceptions.customer import (
|
||||
CustomerValidationException,
|
||||
DuplicateCustomerEmailException,
|
||||
InvalidCustomerCredentialsException,
|
||||
InvalidPasswordResetTokenException,
|
||||
PasswordTooShortException,
|
||||
)
|
||||
from app.exceptions.vendor import VendorNotActiveException, VendorNotFoundException
|
||||
from app.services.auth_service import AuthService
|
||||
from models.database.customer import Customer
|
||||
from models.database.password_reset_token import PasswordResetToken
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.auth import UserLogin
|
||||
from models.schema.customer import CustomerRegister, CustomerUpdate
|
||||
@@ -410,6 +413,88 @@ class CustomerService:
|
||||
|
||||
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()
|
||||
|
||||
@@ -361,6 +361,6 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/email-templates.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
window.SHOP_CONFIG = {
|
||||
locale: '{{ storefront_locale }}',
|
||||
currency: '{{ storefront_currency }}',
|
||||
language: '{{ request.state.language }}'
|
||||
language: '{{ request.state.language|default("fr") }}'
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3228,7 +3228,12 @@ class ArchitectureValidator:
|
||||
"""Validate template patterns"""
|
||||
print("📄 Validating templates...")
|
||||
|
||||
template_files = list(target_path.glob("app/templates/admin/**/*.html"))
|
||||
# Include admin, vendor, and shop templates
|
||||
template_files = (
|
||||
list(target_path.glob("app/templates/admin/**/*.html")) +
|
||||
list(target_path.glob("app/templates/vendor/**/*.html")) +
|
||||
list(target_path.glob("app/templates/shop/**/*.html"))
|
||||
)
|
||||
self.result.files_checked += len(template_files)
|
||||
|
||||
# TPL-001 exclusion patterns
|
||||
@@ -3254,6 +3259,11 @@ class ArchitectureValidator:
|
||||
# Skip components showcase page
|
||||
is_components_page = "components.html" in file_path.name
|
||||
|
||||
# Determine template type
|
||||
is_admin = "/admin/" in file_path_str or "\\admin\\" in file_path_str
|
||||
is_vendor = "/vendor/" in file_path_str or "\\vendor\\" in file_path_str
|
||||
is_shop = "/shop/" in file_path_str or "\\shop\\" in file_path_str
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
@@ -3298,13 +3308,24 @@ class ArchitectureValidator:
|
||||
|
||||
# TPL-010: Check Alpine variables are defined in JS
|
||||
if not is_base_or_partial and not is_macro and not is_components_page:
|
||||
# Try to find corresponding JS file
|
||||
# Try to find corresponding JS file based on template type
|
||||
# Template: app/templates/admin/messages.html -> JS: static/admin/js/messages.js
|
||||
# Template: app/templates/vendor/analytics.html -> JS: static/vendor/js/analytics.js
|
||||
template_name = file_path.stem # e.g., "messages"
|
||||
js_file = target_path / f"static/admin/js/{template_name}.js"
|
||||
if js_file.exists():
|
||||
js_content = js_file.read_text()
|
||||
self._check_alpine_template_vars(file_path, content, lines, js_content)
|
||||
if is_admin:
|
||||
js_dir = "admin"
|
||||
elif is_vendor:
|
||||
js_dir = "vendor"
|
||||
elif is_shop:
|
||||
js_dir = "shop"
|
||||
else:
|
||||
js_dir = None
|
||||
|
||||
if js_dir:
|
||||
js_file = target_path / f"static/{js_dir}/js/{template_name}.js"
|
||||
if js_file.exists():
|
||||
js_content = js_file.read_text()
|
||||
self._check_alpine_template_vars(file_path, content, lines, js_content)
|
||||
|
||||
# TPL-011: Check for deprecated macros
|
||||
self._check_deprecated_macros(file_path, content, lines)
|
||||
@@ -3339,21 +3360,34 @@ class ArchitectureValidator:
|
||||
if "standalone" in first_lines or "noqa: tpl-001" in first_lines:
|
||||
continue
|
||||
|
||||
# TPL-001: Check for extends
|
||||
# TPL-001: Check for extends (template-type specific)
|
||||
if is_admin:
|
||||
expected_base = "admin/base.html"
|
||||
rule_id = "TPL-001"
|
||||
elif is_vendor:
|
||||
expected_base = "vendor/base.html"
|
||||
rule_id = "TPL-001"
|
||||
elif is_shop:
|
||||
expected_base = "shop/base.html"
|
||||
rule_id = "TPL-001"
|
||||
else:
|
||||
continue # Skip unknown template types
|
||||
|
||||
has_extends = any(
|
||||
"{% extends" in line and "admin/base.html" in line for line in lines
|
||||
"{% extends" in line and expected_base in line for line in lines
|
||||
)
|
||||
|
||||
if not has_extends:
|
||||
template_type = "Admin" if is_admin else "Vendor" if is_vendor else "Shop"
|
||||
self._add_violation(
|
||||
rule_id="TPL-001",
|
||||
rule_id=rule_id,
|
||||
rule_name="Templates must extend base",
|
||||
severity=Severity.ERROR,
|
||||
file_path=file_path,
|
||||
line_number=1,
|
||||
message="Admin template does not extend admin/base.html",
|
||||
message=f"{template_type} template does not extend {expected_base}",
|
||||
context=file_path.name,
|
||||
suggestion="Add {% extends 'admin/base.html' %} at the top, or add {# standalone #} if intentional",
|
||||
suggestion=f"Add {{% extends '{expected_base}' %}} at the top, or add {{# standalone #}} if intentional",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -3408,8 +3442,9 @@ class ArchitectureValidator:
|
||||
content = file_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
# Track if we're inside LANGUAGE_NAMES dicts (allowed to use language names)
|
||||
in_language_names_dict = False
|
||||
# Track if we're inside LANGUAGE_NAMES dicts or SUPPORTED_LANGUAGES lists
|
||||
# (allowed to use language names as display values)
|
||||
in_language_names_block = False
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
@@ -3417,15 +3452,22 @@ class ArchitectureValidator:
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Track LANGUAGE_NAMES/LANGUAGE_NAMES_EN blocks - name values are allowed
|
||||
if (
|
||||
"LANGUAGE_NAMES" in line or "LANGUAGE_NAMES_EN" in line
|
||||
# Track LANGUAGE_NAMES, LANGUAGE_NAMES_EN, or SUPPORTED_LANGUAGES/LOCALES blocks
|
||||
# - name values are allowed in these structures
|
||||
if any(
|
||||
name in line
|
||||
for name in [
|
||||
"LANGUAGE_NAMES",
|
||||
"LANGUAGE_NAMES_EN",
|
||||
"SUPPORTED_LANGUAGES",
|
||||
"SUPPORTED_LOCALES",
|
||||
]
|
||||
) and "=" in line:
|
||||
in_language_names_dict = True
|
||||
if in_language_names_dict and stripped == "}":
|
||||
in_language_names_dict = False
|
||||
in_language_names_block = True
|
||||
if in_language_names_block and stripped in ("}", "]"):
|
||||
in_language_names_block = False
|
||||
continue
|
||||
if in_language_names_dict:
|
||||
if in_language_names_block:
|
||||
continue
|
||||
|
||||
for wrong, correct in invalid_codes:
|
||||
|
||||
@@ -55,22 +55,16 @@ function emailTemplatesPage() {
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [templatesRes, categoriesRes] = await Promise.all([
|
||||
fetch('/api/v1/admin/email-templates'),
|
||||
fetch('/api/v1/admin/email-templates/categories')
|
||||
const [templatesData, categoriesData] = await Promise.all([
|
||||
apiClient.get('/admin/email-templates'),
|
||||
apiClient.get('/admin/email-templates/categories')
|
||||
]);
|
||||
|
||||
if (templatesRes.ok) {
|
||||
this.templates = await templatesRes.json();
|
||||
}
|
||||
|
||||
if (categoriesRes.ok) {
|
||||
const data = await categoriesRes.json();
|
||||
this.categories = data.categories || [];
|
||||
}
|
||||
this.templates = templatesData.templates || [];
|
||||
this.categories = categoriesData.categories || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load email templates:', error);
|
||||
this.showNotification('Failed to load templates', 'error');
|
||||
Utils.showToast('Failed to load templates', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -101,20 +95,19 @@ function emailTemplatesPage() {
|
||||
|
||||
this.loadingTemplate = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
const data = await apiClient.get(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
variables: data.variables || [],
|
||||
required_variables: data.required_variables || []
|
||||
};
|
||||
} else if (response.status === 404) {
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
variables: data.variables || [],
|
||||
required_variables: data.required_variables || []
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
// Template doesn't exist for this language yet
|
||||
this.editForm = {
|
||||
subject: '',
|
||||
@@ -123,11 +116,11 @@ function emailTemplatesPage() {
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
this.showNotification(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
|
||||
Utils.showToast(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
|
||||
} else {
|
||||
console.error('Failed to load template:', error);
|
||||
Utils.showToast('Failed to load template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
this.showNotification('Failed to load template', 'error');
|
||||
} finally {
|
||||
this.loadingTemplate = false;
|
||||
}
|
||||
@@ -150,30 +143,21 @@ function emailTemplatesPage() {
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
await apiClient.put(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text
|
||||
})
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('Template saved successfully', 'success');
|
||||
// Refresh templates list
|
||||
await this.loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
this.showNotification(error.detail || 'Failed to save template', 'error');
|
||||
}
|
||||
Utils.showToast('Template saved successfully', 'success');
|
||||
// Refresh templates list
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
this.showNotification('Failed to save template', 'error');
|
||||
Utils.showToast(error.detail || 'Failed to save template', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -185,28 +169,20 @@ function emailTemplatesPage() {
|
||||
// Use sample variables for preview
|
||||
const sampleVariables = this.getSampleVariables(template.code);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${template.code}/preview`,
|
||||
const data = await apiClient.post(
|
||||
`/admin/email-templates/${template.code}/preview`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_code: template.code,
|
||||
language: 'en',
|
||||
variables: sampleVariables
|
||||
})
|
||||
template_code: template.code,
|
||||
language: 'en',
|
||||
variables: sampleVariables
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.previewData = await response.json();
|
||||
this.showPreviewModal = true;
|
||||
} else {
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
}
|
||||
this.previewData = data;
|
||||
this.showPreviewModal = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to preview template:', error);
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
Utils.showToast('Failed to load preview', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -257,47 +233,29 @@ function emailTemplatesPage() {
|
||||
|
||||
this.sendingTest = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/test`,
|
||||
const result = await apiClient.post(
|
||||
`/admin/email-templates/${this.editingTemplate.code}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_code: this.editingTemplate.code,
|
||||
language: this.editLanguage,
|
||||
to_email: this.testEmailAddress,
|
||||
variables: this.getSampleVariables(this.editingTemplate.code)
|
||||
})
|
||||
template_code: this.editingTemplate.code,
|
||||
language: this.editLanguage,
|
||||
to_email: this.testEmailAddress,
|
||||
variables: this.getSampleVariables(this.editingTemplate.code)
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
this.showTestEmailModal = false;
|
||||
this.testEmailAddress = '';
|
||||
} else {
|
||||
this.showNotification(result.message || 'Failed to send test email', 'error');
|
||||
Utils.showToast(result.message || 'Failed to send test email', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send test email:', error);
|
||||
this.showNotification('Failed to send test email', 'error');
|
||||
Utils.showToast('Failed to send test email', 'error');
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Notifications
|
||||
showNotification(message, type = 'info') {
|
||||
// Use global notification system if available
|
||||
if (window.showToast) {
|
||||
window.showToast(message, type);
|
||||
} else if (window.Alpine && Alpine.store('notifications')) {
|
||||
Alpine.store('notifications').add(message, type);
|
||||
} else {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
191
static/vendor/js/email-templates.js
vendored
191
static/vendor/js/email-templates.js
vendored
@@ -54,6 +54,14 @@ function vendorEmailTemplates() {
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
vendorEmailTemplatesLog.info('Email templates init() called');
|
||||
|
||||
// Call parent init to set vendorCode and other base state
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
@@ -63,37 +71,17 @@ function vendorEmailTemplates() {
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/vendor/email-templates', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.templates = data.templates || [];
|
||||
this.supportedLanguages = data.supported_languages || ['en', 'fr', 'de', 'lb'];
|
||||
} else {
|
||||
const error = await response.json();
|
||||
this.error = error.detail || 'Failed to load templates';
|
||||
}
|
||||
const response = await apiClient.get('/vendor/email-templates');
|
||||
this.templates = response.templates || [];
|
||||
this.supportedLanguages = response.supported_languages || ['en', 'fr', 'de', 'lb'];
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to load templates:', error);
|
||||
this.error = 'Failed to load templates';
|
||||
this.error = error.detail || 'Failed to load templates';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Auth token helper
|
||||
getAuthToken() {
|
||||
// Get from cookie or localStorage depending on your auth setup
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('vendor_token='))
|
||||
?.split('=')[1] || '';
|
||||
},
|
||||
|
||||
// Category styling
|
||||
getCategoryClass(category) {
|
||||
const classes = {
|
||||
@@ -121,24 +109,18 @@ function vendorEmailTemplates() {
|
||||
this.loadingTemplate = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
}
|
||||
}
|
||||
const data = await apiClient.get(
|
||||
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.templateSource = data.source;
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || ''
|
||||
};
|
||||
} else if (response.status === 404) {
|
||||
this.templateSource = data.source;
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || ''
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
// No template for this language
|
||||
this.templateSource = 'none';
|
||||
this.editForm = {
|
||||
@@ -146,11 +128,11 @@ function vendorEmailTemplates() {
|
||||
body_html: '',
|
||||
body_text: ''
|
||||
};
|
||||
this.showNotification(`No template available for ${this.editLanguage.toUpperCase()}`, 'info');
|
||||
Utils.showToast(`No template available for ${this.editLanguage.toUpperCase()}`, 'info');
|
||||
} else {
|
||||
vendorEmailTemplatesLog.error('Failed to load template:', error);
|
||||
Utils.showToast('Failed to load template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to load template:', error);
|
||||
this.showNotification('Failed to load template', 'error');
|
||||
} finally {
|
||||
this.loadingTemplate = false;
|
||||
}
|
||||
@@ -172,34 +154,22 @@ function vendorEmailTemplates() {
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
await apiClient.put(
|
||||
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text || null
|
||||
})
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text || null
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('Template saved successfully', 'success');
|
||||
this.templateSource = 'vendor_override';
|
||||
// Refresh list to show updated status
|
||||
await this.loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
this.showNotification(error.detail || 'Failed to save template', 'error');
|
||||
}
|
||||
Utils.showToast('Template saved successfully', 'success');
|
||||
this.templateSource = 'vendor_override';
|
||||
// Refresh list to show updated status
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to save template:', error);
|
||||
this.showNotification('Failed to save template', 'error');
|
||||
Utils.showToast(error.detail || 'Failed to save template', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@@ -215,29 +185,18 @@ function vendorEmailTemplates() {
|
||||
this.reverting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
}
|
||||
}
|
||||
await apiClient.delete(
|
||||
`/vendor/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('Reverted to platform default', 'success');
|
||||
// Reload the template to show platform version
|
||||
await this.loadTemplateLanguage();
|
||||
// Refresh list
|
||||
await this.loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
this.showNotification(error.detail || 'Failed to revert', 'error');
|
||||
}
|
||||
Utils.showToast('Reverted to platform default', 'success');
|
||||
// Reload the template to show platform version
|
||||
await this.loadTemplateLanguage();
|
||||
// Refresh list
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to revert template:', error);
|
||||
this.showNotification('Failed to revert', 'error');
|
||||
Utils.showToast(error.detail || 'Failed to revert', 'error');
|
||||
} finally {
|
||||
this.reverting = false;
|
||||
}
|
||||
@@ -248,30 +207,19 @@ function vendorEmailTemplates() {
|
||||
if (!this.editingTemplate) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/preview`,
|
||||
const data = await apiClient.post(
|
||||
`/vendor/email-templates/${this.editingTemplate.code}/preview`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
language: this.editLanguage,
|
||||
variables: {}
|
||||
})
|
||||
language: this.editLanguage,
|
||||
variables: {}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.previewData = await response.json();
|
||||
this.showPreviewModal = true;
|
||||
} else {
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
}
|
||||
this.previewData = data;
|
||||
this.showPreviewModal = true;
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to preview template:', error);
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
Utils.showToast('Failed to load preview', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -286,49 +234,28 @@ function vendorEmailTemplates() {
|
||||
this.sendingTest = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/vendor/email-templates/${this.editingTemplate.code}/test`,
|
||||
const result = await apiClient.post(
|
||||
`/vendor/email-templates/${this.editingTemplate.code}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to_email: this.testEmailAddress,
|
||||
language: this.editLanguage,
|
||||
variables: {}
|
||||
})
|
||||
to_email: this.testEmailAddress,
|
||||
language: this.editLanguage,
|
||||
variables: {}
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
Utils.showToast(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
this.showTestEmailModal = false;
|
||||
this.testEmailAddress = '';
|
||||
} else {
|
||||
this.showNotification(result.message || 'Failed to send test email', 'error');
|
||||
Utils.showToast(result.message || 'Failed to send test email', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
vendorEmailTemplatesLog.error('Failed to send test email:', error);
|
||||
this.showNotification('Failed to send test email', 'error');
|
||||
Utils.showToast('Failed to send test email', 'error');
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Notifications
|
||||
showNotification(message, type = 'info') {
|
||||
// Use global notification system if available
|
||||
if (window.showToast) {
|
||||
window.showToast(message, type);
|
||||
} else if (window.Alpine && Alpine.store('notifications')) {
|
||||
Alpine.store('notifications').add(message, type);
|
||||
} else {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user