Files
orion/docs/development/architecture-fixes-2026-01.md
Samir Boulahtit d648c921b7
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
docs: add consolidated dev URL reference and migrate /shop to /storefront
- Add Development URL Quick Reference section to url-routing overview
  with all login URLs, entry points, and full examples
- Replace /shop/ path segments with /storefront/ across 50 docs files
- Update file references: shop_pages.py → storefront_pages.py,
  templates/shop/ → templates/storefront/, api/v1/shop/ → api/v1/storefront/
- Preserve domain references (orion.shop) and /store/ staff dashboard paths
- Archive docs left unchanged (historical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:23:44 +01:00

13 KiB

Architecture Validation Fixes (January 2026)

Overview

This document details the architecture validation fixes implemented to achieve zero architecture errors in the codebase. The fixes addressed violations across API endpoints, service layer, JavaScript files, and templates.

Before: 62 errors After: 0 errors


Summary of Changes

Category Files Changed Violations Fixed
API Layer 3 files 12 violations
Service Layer 2 files Business logic moved
Exceptions 1 file 2 new exceptions
JavaScript 2 files 8 violations
Templates 2 files 2 violations
Tooling 1 file 1 false positive fix

1. API Layer Fixes

1.1 Store Settings API

File: app/api/v1/store/settings.py

Problem: 10 HTTPException raises directly in endpoint functions (API-003 violations).

Solution: Moved validation to Pydantic field validators.

Before (Inline Validation)

@router.put("/letzshop")
def update_letzshop_settings(letzshop_config: LetzshopFeedSettingsUpdate, ...):
    update_data = letzshop_config.model_dump(exclude_unset=True)

    # Validate tax rate - VIOLATION: HTTPException in endpoint
    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="Invalid tax rate")
    # ... more inline validation

After (Pydantic Validators)

# Constants moved above model
VALID_TAX_RATES = [0, 3, 8, 14, 17]
VALID_DELIVERY_METHODS = ["nationwide", "package_delivery", "self_collect"]

class LetzshopFeedSettingsUpdate(BaseModel):
    letzshop_default_tax_rate: int | None = Field(None)
    letzshop_boost_sort: str | None = Field(None)
    letzshop_delivery_method: str | None = Field(None)
    letzshop_preorder_days: int | None = Field(None, ge=0)  # Built-in validation

    @field_validator("letzshop_default_tax_rate")
    @classmethod
    def validate_tax_rate(cls, v: int | None) -> int | None:
        if v is not None and v not in VALID_TAX_RATES:
            raise ValueError(f"Invalid tax rate. Must be one of: {VALID_TAX_RATES}")
        return v

    @field_validator("letzshop_delivery_method")
    @classmethod
    def validate_delivery_method(cls, v: str | None) -> str | None:
        if v is not None:
            methods = v.split(",")
            for method in methods:
                if method.strip() not in VALID_DELIVERY_METHODS:
                    raise ValueError(f"Invalid delivery method")
        return v

    @field_validator("letzshop_boost_sort")
    @classmethod
    def validate_boost_sort(cls, v: str | None) -> str | None:
        if v is not None:
            try:
                boost = float(v)
                if boost < 0.0 or boost > 10.0:
                    raise ValueError("Boost sort must be between 0.0 and 10.0")
            except ValueError as e:
                if "could not convert" in str(e).lower():
                    raise ValueError("Boost sort must be a valid number")
                raise
        return v

# Endpoint now clean
@router.put("/letzshop")
def update_letzshop_settings(letzshop_config: LetzshopFeedSettingsUpdate, ...):
    """Validation is handled by Pydantic model validators."""
    store = store_service.get_store_by_id(db, current_user.token_store_id)
    update_data = letzshop_config.model_dump(exclude_unset=True)

    for key, value in update_data.items():
        setattr(store, key, value)
    # ...

Same pattern applied to:

  • LocalizationSettingsUpdate - language and locale validation

1.2 Admin Email Templates API

File: app/api/v1/admin/email_templates.py

Problem: 2 endpoints returning raw dicts instead of Pydantic models (API-001 violations).

Solution: Added Pydantic response models.

Before

@router.get("")
def list_templates(...):
    return {"templates": service.list_platform_templates()}

@router.get("/categories")
def get_categories(...):
    return {"categories": service.get_template_categories()}

After

class TemplateListItem(BaseModel):
    code: str
    name: str
    description: str | None = None
    category: str
    languages: list[str]  # Matches service output
    is_platform_only: bool = False
    variables: list[str] = []

    class Config:
        from_attributes = True

class TemplateListResponse(BaseModel):
    templates: list[TemplateListItem]

class CategoriesResponse(BaseModel):
    categories: list[str]

@router.get("", response_model=TemplateListResponse)
def list_templates(...):
    return TemplateListResponse(templates=service.list_platform_templates())

@router.get("/categories", response_model=CategoriesResponse)
def get_categories(...):
    return CategoriesResponse(categories=service.get_template_categories())

1.3 Shop Auth API (Password Reset)

File: app/api/v1/storefront/auth.py

Problem: Business logic in forgot_password() and reset_password() endpoints.

Solution: Moved business logic to CustomerService.

Before (Business Logic in Endpoint)

@router.post("/auth/reset-password")
def reset_password(request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)):
    # Validate password length - BUSINESS LOGIC
    if len(new_password) < 8:
        raise HTTPException(status_code=400, detail="Password too short")

    # Find valid token - BUSINESS LOGIC
    token_record = PasswordResetToken.find_valid_token(db, reset_token)
    if not token_record:
        raise HTTPException(status_code=400, detail="Invalid token")

    # ... more business logic

After (Delegated to Service)

@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)):
    store = getattr(request.state, "store", None)
    if not store:
        raise StoreNotFoundException("context", identifier_type="subdomain")

    # All business logic in service
    customer = customer_service.validate_and_reset_password(
        db=db,
        store_id=store.id,
        reset_token=reset_token,
        new_password=new_password,
    )
    db.commit()

    return PasswordResetResponse(message="Password reset successfully.")

2. Service Layer Changes

2.1 CustomerService Enhancements

File: app/services/customer_service.py

Added two new methods for password reset:

def get_customer_for_password_reset(
    self, db: Session, store_id: int, email: str
) -> Customer | None:
    """Get active customer by email for password reset."""
    return (
        db.query(Customer)
        .filter(
            Customer.store_id == store_id,
            Customer.email == email.lower(),
            Customer.is_active == True,
        )
        .first()
    )

def validate_and_reset_password(
    self,
    db: Session,
    store_id: int,
    reset_token: str,
    new_password: str,
) -> Customer:
    """Validate reset token and update customer password.

    Raises:
        PasswordTooShortException: If password too short
        InvalidPasswordResetTokenException: If token invalid/expired
        CustomerNotActiveException: If customer not active
    """
    if len(new_password) < 8:
        raise PasswordTooShortException(min_length=8)

    token_record = PasswordResetToken.find_valid_token(db, reset_token)
    if not token_record:
        raise InvalidPasswordResetTokenException()

    customer = db.query(Customer).filter(Customer.id == token_record.customer_id).first()
    if not customer or customer.store_id != store_id:
        raise InvalidPasswordResetTokenException()

    if not customer.is_active:
        raise CustomerNotActiveException(customer.email)

    hashed_password = self.auth_service.hash_password(new_password)
    customer.hashed_password = hashed_password
    token_record.mark_used(db)

    return customer

3. Exception Layer

3.1 New Customer Exceptions

File: app/exceptions/customer.py

Added two new exceptions for password reset:

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"

4. JavaScript Fixes

4.1 Admin Email Templates

File: static/admin/js/email-templates.js

Problems:

  • Used raw fetch() instead of apiClient
  • Used showNotification() instead of Utils.showToast()

Before

async loadData() {
    this.loading = true;
    try {
        const response = await fetch('/api/v1/admin/email-templates');
        const data = await response.json();
        this.templates = data.templates || [];
    } catch (error) {
        showNotification('Failed to load templates', 'error');
    }
}

After

async loadData() {
    this.loading = true;
    try {
        const [templatesData, categoriesData] = await Promise.all([
            apiClient.get('/admin/email-templates'),
            apiClient.get('/admin/email-templates/categories')
        ]);
        this.templates = templatesData.templates || [];
        this.categories = categoriesData.categories || [];
    } catch (error) {
        Utils.showToast('Failed to load templates', 'error');
    }
}

4.2 Store Email Templates

File: static/store/js/email-templates.js

Problems:

  • Used raw fetch() instead of apiClient
  • Missing parent init call pattern
  • Used showNotification() instead of Utils.showToast()

Before

async init() {
    await this.loadData();
}

After

async init() {
    storeEmailTemplatesLog.info('Email templates init() called');

    // Call parent init to set storeCode and other base state
    const parentInit = data().init;
    if (parentInit) {
        await parentInit.call(this);
    }

    await this.loadData();
}

5. Template Fixes

5.1 Admin Email Templates Template

File: app/templates/admin/email-templates.html

Problem: Wrong block name (TPL-015 violation).

<!-- Before -->
{% block scripts %}

<!-- After -->
{% block extra_scripts %}

5.2 Shop Base Template

File: app/templates/storefront/base.html

Problem: request.state.language used without default (LANG-009 violation).

<!-- Before -->
language: '{{ request.state.language }}'

<!-- After -->
language: '{{ request.state.language|default("fr") }}'

6. Architecture Validator Fix

6.1 LANG-001 False Positive

File: scripts/validate/validate_architecture.py

Problem: Validator flagged "English" as invalid language code in SUPPORTED_LANGUAGES dict (where it's a display name, not a code).

Solution: Extended pattern to skip SUPPORTED_LANGUAGES and SUPPORTED_LOCALES blocks.

# Before: Only skipped LANGUAGE_NAMES blocks
if ("LANGUAGE_NAMES" in line or "LANGUAGE_NAMES_EN" in line) and "=" in line:
    in_language_names_dict = True

# After: Also skips SUPPORTED_LANGUAGES and SUPPORTED_LOCALES
if any(
    name in line
    for name in [
        "LANGUAGE_NAMES",
        "LANGUAGE_NAMES_EN",
        "SUPPORTED_LANGUAGES",
        "SUPPORTED_LOCALES",
    ]
) and "=" in line:
    in_language_names_block = True
if in_language_names_block and stripped in ("}", "]"):
    in_language_names_block = False

Architecture Rules Reference

Rule ID Description Fixed Files
API-001 Endpoint must use Pydantic models admin/email_templates.py
API-003 Endpoint must NOT contain business logic store/settings.py, storefront/auth.py
JS-001 Must use apiClient for API calls email-templates.js (admin & store)
JS-002 Must use Utils.showToast() for notifications email-templates.js (admin & store)
JS-003 Must call parent init for Alpine components store/email-templates.js
TPL-015 Must use correct block names admin/email-templates.html
LANG-009 Must provide language default storefront/base.html

Testing

After these changes:

$ python scripts/validate/validate_architecture.py

Files checked: 439
Findings: 0 errors, 141 warnings, 2 info

The remaining warnings are acceptable patterns (mostly db.commit() in services which is allowed for complex operations).


Commit Reference

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

10 files changed, 394 insertions(+), 380 deletions(-)