From 5155ef744518084dd8177e8da721d3501052a40e Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 3 Jan 2026 18:48:59 +0100 Subject: [PATCH] fix: resolve all architecture validation errors (62 -> 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/v1/admin/email_templates.py | 33 +++- app/api/v1/shop/auth.py | 54 +------ app/api/v1/vendor/settings.py | 160 +++++++++---------- app/exceptions/customer.py | 23 +++ app/services/customer_service.py | 85 ++++++++++ app/templates/admin/email-templates.html | 2 +- app/templates/shop/base.html | 2 +- scripts/validate_architecture.py | 82 +++++++--- static/admin/js/email-templates.js | 136 ++++++---------- static/vendor/js/email-templates.js | 191 +++++++---------------- 10 files changed, 391 insertions(+), 377 deletions(-) diff --git a/app/api/v1/admin/email_templates.py b/app/api/v1/admin/email_templates.py index 44e6759e..57ad0a69 100644 --- a/app/api/v1/admin/email_templates.py +++ b/app/api/v1/admin/email_templates.py @@ -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}") diff --git a/app/api/v1/shop/auth.py b/app/api/v1/shop/auth.py index e0f83cec..e623bbaf 100644 --- a/app/api/v1/shop/auth.py +++ b/app/api/v1/shop/auth.py @@ -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() diff --git a/app/api/v1/vendor/settings.py b/app/api/v1/vendor/settings.py index cea9646f..349ce530 100644 --- a/app/api/v1/vendor/settings.py +++ b/app/api/v1/vendor/settings.py @@ -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) diff --git a/app/exceptions/customer.py b/app/exceptions/customer.py index 055cf6ff..892d8d71 100644 --- a/app/exceptions/customer.py +++ b/app/exceptions/customer.py @@ -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" diff --git a/app/services/customer_service.py b/app/services/customer_service.py index 013cf82c..ef55634f 100644 --- a/app/services/customer_service.py +++ b/app/services/customer_service.py @@ -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() diff --git a/app/templates/admin/email-templates.html b/app/templates/admin/email-templates.html index d0c3aa5f..cb350247 100644 --- a/app/templates/admin/email-templates.html +++ b/app/templates/admin/email-templates.html @@ -361,6 +361,6 @@ {% endblock %} -{% block scripts %} +{% block extra_scripts %} {% endblock %} diff --git a/app/templates/shop/base.html b/app/templates/shop/base.html index 3a14bb52..470d163a 100644 --- a/app/templates/shop/base.html +++ b/app/templates/shop/base.html @@ -313,7 +313,7 @@ window.SHOP_CONFIG = { locale: '{{ storefront_locale }}', currency: '{{ storefront_currency }}', - language: '{{ request.state.language }}' + language: '{{ request.state.language|default("fr") }}' }; diff --git a/scripts/validate_architecture.py b/scripts/validate_architecture.py index 3bf43e62..af5cb707 100755 --- a/scripts/validate_architecture.py +++ b/scripts/validate_architecture.py @@ -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: diff --git a/static/admin/js/email-templates.js b/static/admin/js/email-templates.js index 6ae907ed..fc024bba 100644 --- a/static/admin/js/email-templates.js +++ b/static/admin/js/email-templates.js @@ -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}`); - } } }; } diff --git a/static/vendor/js/email-templates.js b/static/vendor/js/email-templates.js index 23c7c2b5..e96944f7 100644 --- a/static/vendor/js/email-templates.js +++ b/static/vendor/js/email-templates.js @@ -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}`); - } } }; }