Add DEMO feature to the project
This commit is contained in:
259
gateway/app/middleware/demo_middleware.py
Normal file
259
gateway/app/middleware/demo_middleware.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Demo Session Middleware
|
||||
Handles demo account restrictions and virtual tenant injection
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from typing import Optional
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Demo tenant IDs (base templates)
|
||||
DEMO_TENANT_IDS = {
|
||||
"a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6", # Panadería San Pablo
|
||||
"b2c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7", # Panadería La Espiga
|
||||
}
|
||||
|
||||
# Allowed operations for demo accounts (limited write)
|
||||
DEMO_ALLOWED_OPERATIONS = {
|
||||
# Read operations - all allowed
|
||||
"GET": ["*"],
|
||||
|
||||
# Limited write operations for realistic testing
|
||||
"POST": [
|
||||
"/api/pos/sales",
|
||||
"/api/pos/sessions",
|
||||
"/api/orders",
|
||||
"/api/inventory/adjustments",
|
||||
"/api/sales",
|
||||
"/api/production/batches",
|
||||
# Note: Forecast generation is explicitly blocked (see DEMO_BLOCKED_PATHS)
|
||||
],
|
||||
|
||||
"PUT": [
|
||||
"/api/pos/sales/*",
|
||||
"/api/orders/*",
|
||||
"/api/inventory/stock/*",
|
||||
],
|
||||
|
||||
# Blocked operations
|
||||
"DELETE": [], # No deletes allowed
|
||||
"PATCH": [], # No patches allowed
|
||||
}
|
||||
|
||||
# Explicitly blocked paths for demo accounts (even if method would be allowed)
|
||||
# These require trained AI models which demo tenants don't have
|
||||
DEMO_BLOCKED_PATHS = [
|
||||
"/api/forecasts/single",
|
||||
"/api/forecasts/multi-day",
|
||||
"/api/forecasts/batch",
|
||||
]
|
||||
|
||||
DEMO_BLOCKED_PATH_MESSAGE = {
|
||||
"forecasts": {
|
||||
"message": "La generación de pronósticos no está disponible para cuentas demo. "
|
||||
"Las cuentas demo no tienen modelos de IA entrenados.",
|
||||
"message_en": "Forecast generation is not available for demo accounts. "
|
||||
"Demo accounts do not have trained AI models.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DemoMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to handle demo session logic"""
|
||||
|
||||
def __init__(self, app, demo_session_url: str = "http://demo-session-service:8000"):
|
||||
super().__init__(app)
|
||||
self.demo_session_url = demo_session_url
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
"""Process request through demo middleware"""
|
||||
|
||||
# Skip demo middleware for demo service endpoints
|
||||
demo_service_paths = [
|
||||
"/api/v1/demo/accounts",
|
||||
"/api/v1/demo/session/create",
|
||||
"/api/v1/demo/session/extend",
|
||||
"/api/v1/demo/session/destroy",
|
||||
"/api/v1/demo/stats",
|
||||
]
|
||||
|
||||
if any(request.url.path.startswith(path) or request.url.path == path for path in demo_service_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# Extract session ID from header or cookie
|
||||
session_id = (
|
||||
request.headers.get("X-Demo-Session-Id") or
|
||||
request.cookies.get("demo_session_id")
|
||||
)
|
||||
|
||||
logger.info(f"🎭 DemoMiddleware - path: {request.url.path}, session_id: {session_id}")
|
||||
|
||||
# Extract tenant ID from request
|
||||
tenant_id = request.headers.get("X-Tenant-Id")
|
||||
|
||||
# Check if this is a demo session request
|
||||
if session_id:
|
||||
try:
|
||||
# Get session info from demo service
|
||||
session_info = await self._get_session_info(session_id)
|
||||
|
||||
if session_info and session_info.get("status") == "active":
|
||||
# Inject virtual tenant ID
|
||||
request.state.tenant_id = session_info["virtual_tenant_id"]
|
||||
request.state.is_demo_session = True
|
||||
request.state.demo_account_type = session_info["demo_account_type"]
|
||||
|
||||
# Inject demo user context for auth middleware
|
||||
# This allows the request to pass through AuthMiddleware
|
||||
request.state.user = {
|
||||
"user_id": session_info.get("user_id", "demo-user"),
|
||||
"email": f"demo-{session_id}@demo.local",
|
||||
"tenant_id": session_info["virtual_tenant_id"],
|
||||
"is_demo": True,
|
||||
"demo_session_id": session_id
|
||||
}
|
||||
|
||||
# Update activity
|
||||
await self._update_session_activity(session_id)
|
||||
|
||||
# Check if path is explicitly blocked
|
||||
blocked_reason = self._check_blocked_path(request.url.path)
|
||||
if blocked_reason:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "demo_restriction",
|
||||
**blocked_reason,
|
||||
"upgrade_url": "/pricing",
|
||||
"session_expires_at": session_info.get("expires_at")
|
||||
}
|
||||
)
|
||||
|
||||
# Check if operation is allowed
|
||||
if not self._is_operation_allowed(request.method, request.url.path):
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "demo_restriction",
|
||||
"message": "Esta operación no está permitida en cuentas demo. "
|
||||
"Las sesiones demo se eliminan automáticamente después de 30 minutos. "
|
||||
"Suscríbete para obtener acceso completo.",
|
||||
"message_en": "This operation is not allowed in demo accounts. "
|
||||
"Demo sessions are automatically deleted after 30 minutes. "
|
||||
"Subscribe for full access.",
|
||||
"upgrade_url": "/pricing",
|
||||
"session_expires_at": session_info.get("expires_at")
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Session expired or invalid
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "session_expired",
|
||||
"message": "Tu sesión demo ha expirado. Crea una nueva sesión para continuar.",
|
||||
"message_en": "Your demo session has expired. Create a new session to continue."
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Demo middleware error", error=str(e), session_id=session_id, path=request.url.path)
|
||||
# On error, return 401 instead of continuing
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "session_error",
|
||||
"message": "Error validando sesión demo. Por favor, inténtalo de nuevo.",
|
||||
"message_en": "Error validating demo session. Please try again."
|
||||
}
|
||||
)
|
||||
|
||||
# Check if this is a demo tenant (base template)
|
||||
elif tenant_id in DEMO_TENANT_IDS:
|
||||
# Direct access to demo tenant without session - block writes
|
||||
request.state.is_demo_session = True
|
||||
request.state.tenant_id = tenant_id
|
||||
|
||||
if request.method not in ["GET", "HEAD", "OPTIONS"]:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "demo_restriction",
|
||||
"message": "Acceso directo al tenant demo no permitido. Crea una sesión demo.",
|
||||
"message_en": "Direct access to demo tenant not allowed. Create a demo session."
|
||||
}
|
||||
)
|
||||
|
||||
# Proceed with request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add demo session header to response if demo session
|
||||
if hasattr(request.state, "is_demo_session") and request.state.is_demo_session:
|
||||
response.headers["X-Demo-Session"] = "true"
|
||||
|
||||
return response
|
||||
|
||||
async def _get_session_info(self, session_id: str) -> Optional[dict]:
|
||||
"""Get session information from demo service"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.demo_session_url}/api/demo/session/{session_id}"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Failed to get session info", session_id=session_id, error=str(e))
|
||||
return None
|
||||
|
||||
async def _update_session_activity(self, session_id: str):
|
||||
"""Update session activity timestamp"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
await client.post(
|
||||
f"{self.demo_session_url}/api/demo/session/{session_id}/activity"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to update activity", session_id=session_id, error=str(e))
|
||||
|
||||
def _check_blocked_path(self, path: str) -> Optional[dict]:
|
||||
"""Check if path is explicitly blocked for demo accounts"""
|
||||
for blocked_path in DEMO_BLOCKED_PATHS:
|
||||
if blocked_path in path:
|
||||
# Determine which category of blocked path
|
||||
if "forecast" in blocked_path:
|
||||
return DEMO_BLOCKED_PATH_MESSAGE["forecasts"]
|
||||
# Can add more categories here in the future
|
||||
return {
|
||||
"message": "Esta funcionalidad no está disponible para cuentas demo.",
|
||||
"message_en": "This functionality is not available for demo accounts."
|
||||
}
|
||||
return None
|
||||
|
||||
def _is_operation_allowed(self, method: str, path: str) -> bool:
|
||||
"""Check if method + path combination is allowed for demo"""
|
||||
|
||||
allowed_paths = DEMO_ALLOWED_OPERATIONS.get(method, [])
|
||||
|
||||
# Check for wildcard
|
||||
if "*" in allowed_paths:
|
||||
return True
|
||||
|
||||
# Check for exact match or pattern match
|
||||
for allowed_path in allowed_paths:
|
||||
if allowed_path.endswith("*"):
|
||||
# Pattern match: /api/orders/* matches /api/orders/123
|
||||
if path.startswith(allowed_path[:-1]):
|
||||
return True
|
||||
elif path == allowed_path:
|
||||
# Exact match
|
||||
return True
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user