refactor: centralize frontend detection with FrontendDetector

Major architecture change to unify frontend detection:

## Problem Solved
- Eliminated code duplication across 3 middleware files
- Fixed incomplete path detection (now detects /api/v1/admin/*)
- Unified on FrontendType enum (deprecates RequestContext)
- Added request.state.frontend_type for all requests

## New Components
- app/core/frontend_detector.py: Centralized FrontendDetector class
- middleware/frontend_type.py: FrontendTypeMiddleware (replaces ContextMiddleware)
- docs/architecture/frontend-detection.md: Complete architecture documentation

## Changes
- main.py: Use FrontendTypeMiddleware instead of ContextMiddleware
- middleware/context.py: Deprecated (kept for backwards compatibility)
- middleware/platform_context.py: Use FrontendDetector.is_admin()
- middleware/vendor_context.py: Use FrontendDetector.is_admin()
- middleware/language.py: Use FrontendType instead of context_value
- app/exceptions/handler.py: Use FrontendType.STOREFRONT
- app/exceptions/error_renderer.py: Use FrontendType
- Customer routes: Cookie path changed from /shop to /storefront

## Documentation
- docs/architecture/frontend-detection.md: New comprehensive docs
- docs/architecture/middleware.md: Updated for new system
- docs/architecture/request-flow.md: Updated for FrontendType
- docs/backend/middleware-reference.md: Updated API reference

## Tests
- tests/unit/core/test_frontend_detector.py: 37 new tests
- tests/unit/middleware/test_frontend_type.py: 11 new tests
- tests/unit/middleware/test_context.py: Updated for compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 16:15:19 +01:00
parent e77535e2cd
commit b769f5a047
17 changed files with 1393 additions and 915 deletions

View File

@@ -14,7 +14,8 @@ from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from middleware.context import RequestContext, get_request_context
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
logger = logging.getLogger(__name__)
@@ -65,11 +66,11 @@ class ErrorPageRenderer:
show_debug: bool = False,
) -> HTMLResponse:
"""
Render appropriate error page based on request context.
Render appropriate error page based on request frontend type.
Template Selection Priority:
1. Context-specific error page: {context}/errors/{status_code}.html
2. Context-specific generic: {context}/errors/generic.html
1. Frontend-specific error page: {frontend}/errors/{status_code}.html
2. Frontend-specific generic: {frontend}/errors/generic.html
3. Shared fallback error page: shared/{status_code}-fallback.html
4. Shared fallback generic: shared/generic-fallback.html
@@ -84,13 +85,13 @@ class ErrorPageRenderer:
Returns:
HTMLResponse with rendered error page
"""
# Get request context
context_type = get_request_context(request)
# Get frontend type
frontend_type = get_frontend_type(request)
# Prepare template data
template_data = ErrorPageRenderer._prepare_template_data(
request=request,
context_type=context_type,
frontend_type=frontend_type,
status_code=status_code,
error_code=error_code,
message=message,
@@ -104,16 +105,16 @@ class ErrorPageRenderer:
# Try to find appropriate template
template_path = ErrorPageRenderer._find_template(
context_type=context_type,
frontend_type=frontend_type,
status_code=status_code,
)
logger.info(
f"Rendering error page: {template_path} for {status_code} in {context_type.value} context",
f"Rendering error page: {template_path} for {status_code} in {frontend_type.value} frontend",
extra={
"status_code": status_code,
"error_code": error_code,
"context": context_type.value,
"frontend": frontend_type.value,
"template": template_path,
},
)
@@ -141,38 +142,37 @@ class ErrorPageRenderer:
@staticmethod
def _find_template(
context_type: RequestContext,
frontend_type: FrontendType,
status_code: int,
) -> str:
"""
Find appropriate error template based on context and status code.
Find appropriate error template based on frontend type and status code.
Priority:
1. {context}/errors/{status_code}.html
2. {context}/errors/generic.html
1. {frontend}/errors/{status_code}.html
2. {frontend}/errors/generic.html
3. shared/{status_code}-fallback.html
4. shared/generic-fallback.html
"""
templates_dir = ErrorPageRenderer.get_templates_dir()
# Map context type to folder name
context_folders = {
RequestContext.ADMIN: "admin",
RequestContext.VENDOR_DASHBOARD: "vendor",
RequestContext.SHOP: "shop",
RequestContext.API: "fallback", # API shouldn't get here, but just in case
RequestContext.FALLBACK: "fallback",
# Map frontend type to folder name
frontend_folders = {
FrontendType.ADMIN: "admin",
FrontendType.VENDOR: "vendor",
FrontendType.STOREFRONT: "storefront",
FrontendType.PLATFORM: "fallback", # Platform uses fallback templates
}
context_folder = context_folders.get(context_type, "fallback")
frontend_folder = frontend_folders.get(frontend_type, "fallback")
# Try context-specific status code template
specific_template = f"{context_folder}/errors/{status_code}.html"
# Try frontend-specific status code template
specific_template = f"{frontend_folder}/errors/{status_code}.html"
if (templates_dir / specific_template).exists():
return specific_template
# Try context-specific generic template
generic_template = f"{context_folder}/errors/generic.html"
# Try frontend-specific generic template
generic_template = f"{frontend_folder}/errors/generic.html"
if (templates_dir / generic_template).exists():
return generic_template
@@ -187,7 +187,7 @@ class ErrorPageRenderer:
@staticmethod
def _prepare_template_data(
request: Request,
context_type: RequestContext,
frontend_type: FrontendType,
status_code: int,
error_code: str,
message: str,
@@ -212,8 +212,8 @@ class ErrorPageRenderer:
# Only show to admins (we can check user role if available)
display_debug = show_debug and ErrorPageRenderer._is_admin_user(request)
# Get context-specific data
context_data = ErrorPageRenderer._get_context_data(request, context_type)
# Get frontend-specific data
frontend_data = ErrorPageRenderer._get_frontend_data(request, frontend_type)
return {
"status_code": status_code,
@@ -222,20 +222,20 @@ class ErrorPageRenderer:
"message": user_message,
"details": details or {},
"show_debug": display_debug,
"context_type": context_type.value,
"frontend_type": frontend_type.value,
"path": request.url.path,
**context_data,
**frontend_data,
}
@staticmethod
def _get_context_data(
request: Request, context_type: RequestContext
def _get_frontend_data(
request: Request, frontend_type: FrontendType
) -> dict[str, Any]:
"""Get context-specific data for error templates."""
"""Get frontend-specific data for error templates."""
data = {}
# Add vendor information if available (for shop context)
if context_type == RequestContext.SHOP:
# Add vendor information if available (for storefront frontend)
if frontend_type == FrontendType.STOREFRONT:
vendor = getattr(request.state, "vendor", None)
if vendor:
# Pass minimal vendor info for templates
@@ -261,7 +261,7 @@ class ErrorPageRenderer:
"custom_css": getattr(theme, "custom_css", None),
}
# Calculate base_url for shop links
# Calculate base_url for storefront links
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
@@ -289,9 +289,9 @@ class ErrorPageRenderer:
This is a placeholder - implement based on your auth system.
"""
# TODO: Implement actual admin check based on JWT/session
# For now, check if we're in admin context
context_type = get_request_context(request)
return context_type == RequestContext.ADMIN
# For now, check if we're in admin frontend
frontend_type = get_frontend_type(request)
return frontend_type == FrontendType.ADMIN
@staticmethod
def _render_basic_html_fallback(

View File

@@ -16,7 +16,8 @@ from fastapi import HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from middleware.context import RequestContext, get_request_context
from app.modules.enums import FrontendType
from middleware.frontend_type import get_frontend_type
from .base import WizamartException
from .error_renderer import ErrorPageRenderer
@@ -382,17 +383,17 @@ def _is_html_page_request(request: Request) -> bool:
def _redirect_to_login(request: Request) -> RedirectResponse:
"""
Redirect to appropriate login page based on request context.
Redirect to appropriate login page based on request frontend type.
Uses context detection to determine admin vs vendor vs shop login.
Uses FrontendType detection to determine admin vs vendor vs storefront login.
Properly handles multi-access routing (domain, subdomain, path-based).
"""
context_type = get_request_context(request)
frontend_type = get_frontend_type(request)
if context_type == RequestContext.ADMIN:
if frontend_type == FrontendType.ADMIN:
logger.debug("Redirecting to /admin/login")
return RedirectResponse(url="/admin/login", status_code=302)
if context_type == RequestContext.VENDOR_DASHBOARD:
if frontend_type == FrontendType.VENDOR:
# Extract vendor code from the request path
# Path format: /vendor/{vendor_code}/...
path_parts = request.url.path.split("/")
@@ -417,8 +418,8 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
if context_type == RequestContext.SHOP:
# For shop context, redirect to shop login (customer login)
if frontend_type == FrontendType.STOREFRONT:
# For storefront context, redirect to storefront login (customer login)
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
vendor = getattr(request.state, "vendor", None)
vendor_context = getattr(request.state, "vendor_context", None)
@@ -437,11 +438,11 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
)
base_url = f"{full_prefix}{vendor.subdomain}/"
login_url = f"{base_url}shop/account/login"
login_url = f"{base_url}storefront/account/login"
logger.debug(f"Redirecting to {login_url}")
return RedirectResponse(url=login_url, status_code=302)
# Fallback to root for unknown contexts
logger.debug("Unknown context, redirecting to /")
# Fallback to root for unknown contexts (PLATFORM)
logger.debug("Platform context, redirecting to /")
return RedirectResponse(url="/", status_code=302)