feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add admin SQL query tool with saved queries, schema explorer presets,
  and collapsible category sections (dev_tools module)
- Add platform debug tool for admin diagnostics
- Add loyalty settings page with owner-only access control
- Fix loyalty settings owner check (use currentUser instead of window.__userData)
- Replace HTTPException with AuthorizationException in loyalty routes
- Expand loyalty module with PIN service, Apple Wallet, program management
- Improve store login with platform detection and multi-platform support
- Update billing feature gates and subscription services
- Add store platform sync improvements and remove is_primary column
- Add unit tests for loyalty (PIN, points, stamps, program services)
- Update i18n translations across dev_tools locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

@@ -6,5 +6,9 @@ Note: Code quality and test running routes have been moved to the monitoring mod
The dev_tools module keeps models but routes are now in monitoring.
"""
# No routes exported - code quality and tests are in monitoring module
__all__ = []
from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
__all__ = ["sql_query_router", "platform_debug_router"]

View File

@@ -0,0 +1,17 @@
# app/modules/dev_tools/routes/api/admin.py
"""
Dev-Tools module admin API routes.
Aggregates all admin API routers for the dev-tools module.
"""
from fastapi import APIRouter
from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"])

View File

@@ -0,0 +1,344 @@
# app/modules/dev_tools/routes/api/admin_platform_debug.py
"""
Platform resolution debug endpoint.
Simulates the middleware pipeline for arbitrary host/path combos
to diagnose platform context issues across all URL patterns.
"""
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_super_admin_api, get_db
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/debug")
logger = logging.getLogger(__name__)
class PlatformTraceStep(BaseModel):
step: str
result: dict | None = None
note: str = ""
class PlatformTraceResponse(BaseModel):
input_host: str
input_path: str
input_platform_code_body: str | None = None
steps: list[PlatformTraceStep]
resolved_platform_code: str | None = None
resolved_platform_id: int | None = None
resolved_store_code: str | None = None
resolved_store_id: int | None = None
login_platform_source: str | None = None
login_platform_code: str | None = None
@router.get("/platform-trace", response_model=PlatformTraceResponse)
def trace_platform_resolution(
host: str = Query(..., description="Host header to simulate (e.g. localhost:8000, www.omsflow.lu)"),
path: str = Query(..., description="URL path to simulate (e.g. /platforms/loyalty/store/WIZATECH/login)"),
platform_code_body: str | None = Query(None, description="platform_code sent in login request body (Source 2)"),
store_code_body: str | None = Query(None, description="store_code sent in login request body"),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin_api),
):
"""
Simulate the full middleware + login platform resolution pipeline.
Traces each step:
1. PlatformContextMiddleware detection
2. Platform DB lookup
3. Path rewrite
4. StoreContextMiddleware detection
5. Store DB lookup
6. Login handler Source 1/2/3 resolution
"""
from middleware.platform_context import (
_LOCAL_HOSTS,
MAIN_PLATFORM_CODE,
PlatformContextMiddleware,
)
from middleware.store_context import StoreContextManager
steps = []
mw = PlatformContextMiddleware(None)
# ── Step 1: Platform detection ──
platform_context = mw._detect_platform_context(path, host)
steps.append(PlatformTraceStep(
step="1. PlatformContextMiddleware._detect_platform_context()",
result=_sanitize(platform_context),
note="Returns None for admin routes or /store/ on localhost without /platforms/ prefix",
))
# ── Step 1b: Referer fallback (storefront API on localhost) ──
host_without_port = host.split(":")[0] if ":" in host else host
if (
host_without_port in _LOCAL_HOSTS
and path.startswith("/api/v1/storefront/")
and platform_context
and platform_context.get("detection_method") == "default"
):
steps.append(PlatformTraceStep(
step="1b. Referer fallback check",
note="Would check Referer header for /platforms/{code}/ — not simulated here",
))
# ── Step 2: Platform DB lookup ──
from middleware.platform_context import PlatformContextManager
platform = None
if platform_context:
platform = PlatformContextManager.get_platform_from_context(db, platform_context)
platform_dict = None
if platform:
platform_dict = {
"id": platform.id,
"code": platform.code,
"name": platform.name,
"domain": platform.domain,
"path_prefix": platform.path_prefix,
}
steps.append(PlatformTraceStep(
step="2. PlatformContextManager.get_platform_from_context()",
result=platform_dict,
note="DB lookup using detection context" if platform_context else "Skipped — no platform_context",
))
# ── Step 3: Path rewrite ──
clean_path = path
if platform_context and platform_context.get("detection_method") == "path":
clean_path = platform_context.get("clean_path", path)
steps.append(PlatformTraceStep(
step="3. Path rewrite (scope['path'])",
result={"original_path": path, "rewritten_path": clean_path},
note="Dev mode strips /platforms/{code}/ prefix for routing"
if clean_path != path
else "No rewrite — path unchanged",
))
# ── Step 4: StoreContextMiddleware detection ──
# Simulate what StoreContextManager.detect_store_context() would see
# It reads request.state.platform_clean_path which is set to clean_path
store_context = _simulate_store_detection(clean_path, host)
if store_context and platform:
store_context["_platform"] = platform
steps.append(PlatformTraceStep(
step="4. StoreContextManager.detect_store_context()",
result=_sanitize(store_context),
note="Uses rewritten path (platform_clean_path) for detection",
))
# ── Step 5: Store DB lookup ──
store = None
if store_context:
store = StoreContextManager.get_store_from_context(db, store_context)
store_dict = None
if store:
store_dict = {
"id": store.id,
"store_code": store.store_code,
"subdomain": store.subdomain,
"name": store.name,
}
steps.append(PlatformTraceStep(
step="5. StoreContextManager.get_store_from_context()",
result=store_dict,
note="DB lookup using store context" if store_context else "Skipped — no store_context",
))
# ── Step 6: Is this an API path? ──
# The actual API path after rewrite determines middleware behavior
is_api = clean_path.startswith("/api/")
is_store_api = clean_path.startswith(("/store/", "/api/v1/store/"))
steps.append(PlatformTraceStep(
step="6. Route classification",
result={
"is_api_path": is_api,
"is_store_path": is_store_api,
"clean_path": clean_path,
"middleware_skips_store_detection_for_api": is_api and not clean_path.startswith("/api/v1/storefront/"),
},
note="StoreContextMiddleware SKIPS store detection for non-storefront API routes",
))
# ── Step 7: Login handler — Source 1/2/3 ──
from app.modules.tenancy.services.platform_service import platform_service
login_source = None
login_platform = None
login_platform_code = None
# Source 1: middleware platform (only if not "main")
src1_note = ""
if platform and platform.code != MAIN_PLATFORM_CODE:
login_platform = platform
login_source = "Source 1: middleware (request.state.platform)"
src1_note = f"platform.code={platform.code!r} != 'main' → used"
elif platform:
src1_note = f"platform.code={platform.code!r} == 'main' → skipped"
else:
src1_note = "No platform in middleware → skipped"
steps.append(PlatformTraceStep(
step="7a. Login Source 1: middleware platform",
result={"platform_code": platform.code if platform else None, "used": login_source is not None},
note=src1_note,
))
# Source 2: platform_code from body
src2_note = ""
if login_platform is None and platform_code_body:
body_platform = platform_service.get_platform_by_code_optional(db, platform_code_body)
if body_platform:
login_platform = body_platform
login_source = "Source 2: request body (platform_code)"
src2_note = f"Found platform with code={platform_code_body!r} → used"
else:
src2_note = f"No platform found for code={platform_code_body!r} → would raise error"
elif login_platform is not None:
src2_note = "Skipped — Source 1 already resolved"
else:
src2_note = "No platform_code in body → skipped"
steps.append(PlatformTraceStep(
step="7b. Login Source 2: request body platform_code",
result={"platform_code_body": platform_code_body, "used": login_source == "Source 2: request body (platform_code)"},
note=src2_note,
))
# Source 3: fallback to store's first active platform
src3_note = ""
if login_platform is None and store:
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
if primary_pid:
login_platform = platform_service.get_platform_by_id(db, primary_pid)
login_source = "Source 3: get_first_active_platform_id_for_store()"
src3_note = f"Fallback → first active platform for store {store.store_code}: platform_id={primary_pid}"
else:
src3_note = "No active platform for store"
elif login_platform is not None:
src3_note = "Skipped — earlier source already resolved"
# Still show what Source 3 WOULD return for comparison
if store:
primary_pid = platform_service.get_first_active_platform_id_for_store(db, store.id)
if primary_pid:
fallback = platform_service.get_platform_by_id(db, primary_pid)
src3_note += f" (would have been: {fallback.code!r})"
else:
src3_note = "No store resolved — cannot determine fallback"
steps.append(PlatformTraceStep(
step="7c. Login Source 3: store's first active platform (fallback)",
result={
"store_id": store.id if store else None,
"used": login_source == "Source 3: get_first_active_platform_id_for_store()",
},
note=src3_note,
))
if login_platform:
login_platform_code = login_platform.code
# ── Step 8: Final result ──
steps.append(PlatformTraceStep(
step="8. FINAL LOGIN PLATFORM",
result={
"source": login_source,
"platform_code": login_platform_code,
"platform_id": login_platform.id if login_platform else None,
},
note=f"JWT will be minted with platform_code={login_platform_code!r}",
))
return PlatformTraceResponse(
input_host=host,
input_path=path,
input_platform_code_body=platform_code_body,
steps=steps,
resolved_platform_code=platform.code if platform else None,
resolved_platform_id=platform.id if platform else None,
resolved_store_code=store.store_code if store else None,
resolved_store_id=store.id if store else None,
login_platform_source=login_source,
login_platform_code=login_platform_code,
)
def _simulate_store_detection(clean_path: str, host: str) -> dict | None:
"""
Simulate StoreContextManager.detect_store_context() for a given path/host.
Reproduces the same logic without needing a real Request object.
"""
from app.core.config import settings
from app.modules.tenancy.models import StoreDomain
host_without_port = host.split(":")[0] if ":" in host else host
# Method 1: Custom domain
platform_domain = getattr(settings, "platform_domain", "platform.com")
is_custom_domain = (
host_without_port
and not host_without_port.endswith(f".{platform_domain}")
and host_without_port != platform_domain
and host_without_port not in ["localhost", "127.0.0.1", "admin.localhost", "admin.127.0.0.1"]
and not host_without_port.startswith("admin.")
)
if is_custom_domain:
normalized_domain = StoreDomain.normalize_domain(host_without_port)
return {
"domain": normalized_domain,
"detection_method": "custom_domain",
"host": host_without_port,
"original_host": host,
}
# Method 2: Subdomain
if "." in host_without_port:
parts = host_without_port.split(".")
if len(parts) >= 2 and parts[0] not in ["www", "admin", "api"]:
subdomain = parts[0]
return {
"subdomain": subdomain,
"detection_method": "subdomain",
"host": host_without_port,
}
# Method 3: Path-based
if clean_path.startswith(("/store/", "/stores/", "/storefront/")):
if clean_path.startswith("/storefront/"):
prefix_len = len("/storefront/")
elif clean_path.startswith("/stores/"):
prefix_len = len("/stores/")
else:
prefix_len = len("/store/")
path_parts = clean_path[prefix_len:].split("/")
if len(path_parts) >= 1 and path_parts[0]:
store_code = path_parts[0]
return {
"subdomain": store_code,
"detection_method": "path",
"path_prefix": clean_path[:prefix_len + len(store_code)],
"full_prefix": clean_path[:prefix_len],
"host": host_without_port,
}
return None
def _sanitize(d: dict | None) -> dict | None:
"""Remove non-serializable objects from dict."""
if d is None:
return None
return {k: v for k, v in d.items() if not k.startswith("_")}

View File

@@ -0,0 +1,158 @@
# app/modules/dev_tools/routes/api/admin_sql_query.py
"""
SQL Query API endpoints.
All endpoints require super-admin authentication via Bearer token.
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import UserContext, get_current_super_admin_api, get_db
from app.modules.dev_tools.services.sql_query_service import (
create_saved_query,
delete_saved_query,
execute_query,
list_saved_queries,
record_query_run,
update_saved_query,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sql-query", tags=["sql-query"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ExecuteRequest(BaseModel):
sql: str = Field(..., min_length=1, max_length=10000)
saved_query_id: int | None = None
class SavedQueryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
sql_text: str = Field(..., min_length=1, max_length=10000)
description: str | None = None
class SavedQueryUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
sql_text: str | None = Field(None, min_length=1, max_length=10000)
description: str | None = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/execute") # noqa: API001
async def execute_sql(
body: ExecuteRequest,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Execute a read-only SQL query."""
result = execute_query(db, body.sql)
# Track run if this was a saved query
if body.saved_query_id:
try:
record_query_run(db, body.saved_query_id)
db.commit()
except Exception:
db.rollback()
return result
@router.get("/saved") # noqa: API001
async def get_saved_queries(
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""List all saved queries."""
queries = list_saved_queries(db)
return [
{
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"created_by": q.created_by,
"last_run_at": q.last_run_at.isoformat() if q.last_run_at else None,
"run_count": q.run_count,
"created_at": q.created_at.isoformat() if q.created_at else None,
}
for q in queries
]
@router.post("/saved") # noqa: API001
async def create_saved(
body: SavedQueryCreate,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Create a new saved query."""
q = create_saved_query(
db,
name=body.name,
sql_text=body.sql_text,
description=body.description,
created_by=ctx.id,
)
db.commit()
return {
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"created_by": q.created_by,
"run_count": q.run_count,
"created_at": q.created_at.isoformat() if q.created_at else None,
}
@router.put("/saved/{query_id}") # noqa: API001
async def update_saved(
query_id: int,
body: SavedQueryUpdate,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Update a saved query."""
q = update_saved_query(
db,
query_id,
name=body.name,
sql_text=body.sql_text,
description=body.description,
)
db.commit()
return {
"id": q.id,
"name": q.name,
"description": q.description,
"sql_text": q.sql_text,
"run_count": q.run_count,
}
@router.delete("/saved/{query_id}") # noqa: API001
async def delete_saved(
query_id: int,
ctx: UserContext = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Delete a saved query."""
delete_saved_query(db, query_id)
db.commit()
return {"ok": True}