Move 9 init/seed scripts into scripts/seed/ and 7 validation scripts (+ validators/ subfolder) into scripts/validate/ to reduce clutter in the root scripts/ directory. Update all references across Makefile, CI/CD configs, pre-commit hooks, docs (~40 files), and Python imports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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/shop/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 ofapiClient - Used
showNotification()instead ofUtils.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 ofapiClient - Missing parent init call pattern
- Used
showNotification()instead ofUtils.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/shop/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, shop/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 | shop/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(-)