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

@@ -35,6 +35,7 @@ dev_tools_module = ModuleDefinition(
"performance_validation", # Performance validator
"test_runner", # Test execution
"violation_management", # Violation tracking and assignment
"sql_query", # Ad-hoc SQL query tool
],
menu_items={
FrontendType.ADMIN: [
@@ -42,6 +43,7 @@ dev_tools_module = ModuleDefinition(
"icons", # Icon browser page
"code-quality", # Code quality dashboard
"tests", # Test runner dashboard
"sql-query", # SQL query tool
],
FrontendType.STORE: [], # No store menu items - internal module
},
@@ -69,6 +71,20 @@ dev_tools_module = ModuleDefinition(
route="/admin/icons",
order=20,
),
MenuItemDefinition(
id="sql-query",
label_key="dev_tools.menu.sql_query",
icon="database",
route="/admin/sql-query",
order=30,
),
MenuItemDefinition(
id="platform-debug",
label_key="dev_tools.menu.platform_debug",
icon="search",
route="/admin/platform-debug",
order=40,
),
],
),
],
@@ -94,11 +110,8 @@ def get_dev_tools_module_with_routers() -> ModuleDefinition:
"""
Get dev-tools module definition.
Note: API routes have been moved to monitoring module.
This module has no routers to attach.
Note: Admin API routes are auto-discovered from routes/api/admin.py.
"""
# No routers - API routes are now in monitoring module
dev_tools_module.router = None
dev_tools_module.router = None
return dev_tools_module

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Entwicklerwerkzeuge",
"components": "Komponenten",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug"
},
"sql_query": {
"title": "SQL Abfrage-Werkzeug",
"execute": "Abfrage ausführen",
"save": "Abfrage speichern",
"export_csv": "CSV exportieren",
"saved_queries": "Gespeicherte Abfragen",
"no_saved_queries": "Noch keine gespeicherten Abfragen.",
"query_name": "Abfragename",
"description": "Beschreibung",
"forbidden_keyword": "Verbotenes SQL-Schlüsselwort. Nur SELECT-Abfragen sind erlaubt.",
"query_empty": "Abfrage darf nicht leer sein.",
"rows_returned": "Zeilen zurückgegeben",
"results_truncated": "Ergebnisse auf 1000 Zeilen beschränkt",
"execution_time": "Ausführungszeit"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Developer Tools",
"components": "Components",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Query",
"platform_debug": "Platform Debug"
},
"sql_query": {
"title": "SQL Query Tool",
"execute": "Run Query",
"save": "Save Query",
"export_csv": "Export CSV",
"saved_queries": "Saved Queries",
"no_saved_queries": "No saved queries yet.",
"query_name": "Query Name",
"description": "Description",
"forbidden_keyword": "Forbidden SQL keyword. Only SELECT queries are allowed.",
"query_empty": "Query cannot be empty.",
"rows_returned": "rows returned",
"results_truncated": "Results truncated to 1000 rows",
"execution_time": "Execution time"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Outils de développement",
"components": "Composants",
"icons": "Icônes"
"icons": "Icônes",
"sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme"
},
"sql_query": {
"title": "Outil de requête SQL",
"execute": "Exécuter la requête",
"save": "Enregistrer la requête",
"export_csv": "Exporter en CSV",
"saved_queries": "Requêtes enregistrées",
"no_saved_queries": "Aucune requête enregistrée.",
"query_name": "Nom de la requête",
"description": "Description",
"forbidden_keyword": "Mot-clé SQL interdit. Seules les requêtes SELECT sont autorisées.",
"query_empty": "La requête ne peut pas être vide.",
"rows_returned": "lignes retournées",
"results_truncated": "Résultats tronqués à 1000 lignes",
"execution_time": "Temps d'exécution"
}
}

View File

@@ -12,6 +12,23 @@
"menu": {
"developer_tools": "Entwécklerwerkzäicher",
"components": "Komponenten",
"icons": "Icons"
"icons": "Icons",
"sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug"
},
"sql_query": {
"title": "SQL Ufro-Werkzeug",
"execute": "Ufro ausféieren",
"save": "Ufro späicheren",
"export_csv": "CSV exportéieren",
"saved_queries": "Gespäichert Ufroen",
"no_saved_queries": "Nach keng gespäichert Ufroen.",
"query_name": "Ufroennumm",
"description": "Beschreiwung",
"forbidden_keyword": "Verbuedent SQL-Schlësselwuert. Nëmmen SELECT-Ufroen sinn erlaabt.",
"query_empty": "Ufro däerf net eidel sinn.",
"rows_returned": "Zeilen zréckginn",
"results_truncated": "Resultater op 1000 Zeilen beschränkt",
"execution_time": "Ausféierungszäit"
}
}

View File

@@ -0,0 +1,47 @@
"""dev_tools saved queries
Revision ID: dev_tools_002
Revises: dev_tools_001
Create Date: 2026-03-10
"""
import sqlalchemy as sa
from alembic import op
revision = "dev_tools_002"
down_revision = "dev_tools_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"dev_tools_saved_queries",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("name", sa.String(200), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("sql_text", sa.Text(), nullable=False),
sa.Column(
"created_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=False
),
sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"run_count", sa.Integer(), nullable=False, server_default="0"
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("dev_tools_saved_queries")

View File

@@ -25,6 +25,7 @@ from app.modules.dev_tools.models.architecture_scan import (
ViolationAssignment,
ViolationComment,
)
from app.modules.dev_tools.models.saved_query import SavedQuery
from app.modules.dev_tools.models.test_run import (
TestCollection,
TestResult,
@@ -42,4 +43,6 @@ __all__ = [
"TestRun",
"TestResult",
"TestCollection",
# Saved query models
"SavedQuery",
]

View File

@@ -0,0 +1,34 @@
# app/modules/dev_tools/models/saved_query.py
"""
Saved SQL Query Model
Database model for storing frequently-used SQL queries.
"""
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from app.core.database import Base
class SavedQuery(Base):
"""A saved SQL query for quick re-running from the admin UI."""
__tablename__ = "dev_tools_saved_queries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, index=True)
description = Column(Text, nullable=True)
sql_text = Column(Text, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
last_run_at = Column(DateTime(timezone=True), nullable=True)
run_count = Column(Integer, default=0, server_default="0")
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)

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}

View File

@@ -129,3 +129,39 @@ async def admin_test_stores_users_migration(
"dev_tools/admin/test-stores-users-migration.html",
get_admin_context(request, db, current_user),
)
@router.get("/platform-debug", response_class=HTMLResponse, include_in_schema=False)
async def admin_platform_debug_page(
request: Request,
current_user: User = Depends(
require_menu_access("testing", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render platform resolution debug page.
Traces middleware pipeline for all URL patterns.
"""
return templates.TemplateResponse(
"dev_tools/admin/platform-debug.html",
get_admin_context(request, db, current_user),
)
@router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False)
async def admin_sql_query_page(
request: Request,
current_user: User = Depends(
require_menu_access("sql-query", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render SQL query tool page.
Ad-hoc SQL query execution with saved query management.
"""
return templates.TemplateResponse(
"dev_tools/admin/sql-query.html",
get_admin_context(request, db, current_user),
)

View File

@@ -0,0 +1,197 @@
# app/modules/dev_tools/services/sql_query_service.py
"""
SQL Query Service
Provides safe, read-only SQL query execution and saved query CRUD operations.
Security layers:
1. Regex-based DML/DDL rejection
2. SET TRANSACTION READ ONLY on PostgreSQL
3. Statement timeout (30s)
4. Automatic rollback after every execution
"""
import re
import time
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.exceptions.base import ResourceNotFoundException, ValidationException
from app.modules.dev_tools.models.saved_query import SavedQuery
# Forbidden SQL keywords — matches whole words, case-insensitive
_FORBIDDEN_PATTERN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|GRANT|REVOKE|COPY|VACUUM|REINDEX)\b",
re.IGNORECASE,
)
class QueryValidationError(ValidationException):
"""Raised when a query contains forbidden SQL statements."""
def __init__(self, message: str):
super().__init__(message=message, field="sql")
def _strip_sql_comments(sql: str) -> str:
"""Remove SQL comments (-- line comments and /* block comments */)."""
# Remove block comments
result = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
# Remove line comments
result = re.sub(r"--[^\n]*", " ", result)
return result
def validate_query(sql: str) -> None:
"""Validate that the SQL query is SELECT-only (no DML/DDL)."""
stripped = sql.strip().rstrip(";")
if not stripped:
raise QueryValidationError("Query cannot be empty.")
# Strip comments before checking for forbidden keywords
code_only = _strip_sql_comments(stripped)
match = _FORBIDDEN_PATTERN.search(code_only)
if match:
raise QueryValidationError(
f"Forbidden SQL keyword: {match.group().upper()}. Only SELECT queries are allowed."
)
def _make_json_safe(value: Any) -> Any:
"""Convert a database value to a JSON-serializable representation."""
if value is None:
return None
if isinstance(value, int | float | bool):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return f"<bytes({len(value)})>"
if isinstance(value, list | dict):
return value
return str(value)
def execute_query(db: Session, sql: str) -> dict:
"""
Execute a read-only SQL query and return results.
Returns:
dict with columns, rows, row_count, truncated, execution_time_ms
"""
validate_query(sql)
max_rows = 1000
connection = db.connection()
try:
# Set read-only transaction and statement timeout
connection.execute(text("SET TRANSACTION READ ONLY"))
connection.execute(text("SET statement_timeout = '30s'"))
start = time.perf_counter()
result = connection.execute(text(sql))
rows_raw = result.fetchmany(max_rows + 1)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
columns = list(result.keys()) if result.returns_rows else []
truncated = len(rows_raw) > max_rows
rows_raw = rows_raw[:max_rows]
rows = [
[_make_json_safe(cell) for cell in row] for row in rows_raw
]
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"execution_time_ms": elapsed_ms,
}
except (QueryValidationError, ValidationException):
raise
except Exception as e:
raise QueryValidationError(str(e)) from e
finally:
db.rollback()
# ---------------------------------------------------------------------------
# Saved Query CRUD
# ---------------------------------------------------------------------------
def list_saved_queries(db: Session) -> list[SavedQuery]:
"""List all saved queries ordered by name."""
return db.query(SavedQuery).order_by(SavedQuery.name).all()
def create_saved_query(
db: Session,
*,
name: str,
sql_text: str,
description: str | None,
created_by: int,
) -> SavedQuery:
"""Create a new saved query."""
query = SavedQuery(
name=name,
sql_text=sql_text,
description=description,
created_by=created_by,
)
db.add(query)
db.flush()
db.refresh(query)
return query
def update_saved_query(
db: Session,
query_id: int,
*,
name: str | None = None,
sql_text: str | None = None,
description: str | None = None,
) -> SavedQuery:
"""Update an existing saved query. Raises ResourceNotFoundException if not found."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if not query:
raise ResourceNotFoundException("SavedQuery", str(query_id))
if name is not None:
query.name = name
if sql_text is not None:
query.sql_text = sql_text
if description is not None:
query.description = description
db.flush()
db.refresh(query)
return query
def delete_saved_query(db: Session, query_id: int) -> None:
"""Delete a saved query. Raises ResourceNotFoundException if not found."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if not query:
raise ResourceNotFoundException("SavedQuery", str(query_id))
db.delete(query)
db.flush()
def record_query_run(db: Session, query_id: int) -> None:
"""Increment run_count and update last_run_at for a saved query."""
query = db.query(SavedQuery).filter(SavedQuery.id == query_id).first()
if query:
query.run_count = (query.run_count or 0) + 1
query.last_run_at = datetime.now(UTC)
db.flush()

View File

@@ -0,0 +1,249 @@
// app/modules/dev_tools/static/admin/js/sql-query.js
const sqlLog = window.LogConfig.createLogger('SQL_QUERY');
/**
* SQL Query Tool Alpine.js Component
* Execute ad-hoc SQL queries and manage saved queries.
*/
function sqlQueryTool() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'sql-query',
// Editor state
sql: '',
running: false,
error: null,
// Results
columns: [],
rows: [],
rowCount: 0,
truncated: false,
executionTimeMs: null,
// Saved queries
savedQueries: [],
loadingSaved: false,
activeSavedId: null,
// Save modal
showSaveModal: false,
saveName: '',
saveDescription: '',
saving: false,
// Schema explorer
showPresets: true,
expandedCategories: {},
presetQueries: [
{
category: 'Schema',
items: [
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
]
},
{
category: 'Statistics',
items: [
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
]
},
{
category: 'Tenancy',
items: [
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
]
},
{
category: 'Permissions',
items: [
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
]
},
{
category: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
]
},
],
toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category];
},
isCategoryExpanded(category) {
return this.expandedCategories[category] || false;
},
async init() {
if (window._sqlQueryInitialized) return;
window._sqlQueryInitialized = true;
this.$nextTick(() => {
if (typeof this.initBase === 'function') this.initBase();
});
try {
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to initialize:', e);
}
// Ctrl+Enter shortcut
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.executeQuery();
}
});
},
async executeQuery() {
if (!this.sql.trim() || this.running) return;
this.running = true;
this.error = null;
this.columns = [];
this.rows = [];
this.rowCount = 0;
this.truncated = false;
this.executionTimeMs = null;
try {
const payload = { sql: this.sql };
if (this.activeSavedId) {
payload.saved_query_id = this.activeSavedId;
}
const data = await apiClient.post('/admin/sql-query/execute', payload);
this.columns = data.columns;
this.rows = data.rows;
this.rowCount = data.row_count;
this.truncated = data.truncated;
this.executionTimeMs = data.execution_time_ms;
// Refresh saved queries to update run_count
if (this.activeSavedId) {
await this.loadSavedQueries();
}
} catch (e) {
this.error = e.message;
} finally {
this.running = false;
}
},
async loadSavedQueries() {
this.loadingSaved = true;
try {
this.savedQueries = await apiClient.get('/admin/sql-query/saved');
} catch (e) {
sqlLog.error('Failed to load saved queries:', e);
} finally {
this.loadingSaved = false;
}
},
loadQuery(q) {
this.sql = q.sql_text;
this.activeSavedId = q.id;
this.error = null;
},
loadPreset(preset) {
this.sql = preset.sql;
this.activeSavedId = null;
this.error = null;
},
openSaveModal() {
this.saveName = '';
this.saveDescription = '';
this.showSaveModal = true;
},
async saveQuery() {
if (!this.saveName.trim() || !this.sql.trim()) return;
this.saving = true;
try {
const saved = await apiClient.post('/admin/sql-query/saved', {
name: this.saveName,
sql_text: this.sql,
description: this.saveDescription || null,
});
this.activeSavedId = saved.id;
this.showSaveModal = false;
await this.loadSavedQueries();
} catch (e) {
this.error = e.message;
} finally {
this.saving = false;
}
},
async deleteSavedQuery(id) {
if (!confirm('Delete this saved query?')) return;
try {
await apiClient.delete(`/admin/sql-query/saved/${id}`);
if (this.activeSavedId === id) {
this.activeSavedId = null;
}
await this.loadSavedQueries();
} catch (e) {
sqlLog.error('Failed to delete:', e);
}
},
exportCsv() {
if (!this.columns.length || !this.rows.length) return;
const escape = (val) => {
if (val === null || val === undefined) return '';
const s = String(val);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
};
const lines = [this.columns.map(escape).join(',')];
for (const row of this.rows) {
lines.push(row.map(escape).join(','));
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'query-results.csv';
a.click();
URL.revokeObjectURL(url);
},
formatCell(val) {
if (val === null || val === undefined) return 'NULL';
return String(val);
},
isNull(val) {
return val === null || val === undefined;
},
};
}

View File

@@ -0,0 +1,466 @@
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
{% extends "admin/base.html" %}
{% block title %}Platform Resolution Debug{% endblock %}
{% block alpine_data %}platformDebug(){% endblock %}
{% block content %}
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
</p>
</div>
<!-- Controls -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div class="flex items-center gap-4 flex-wrap">
<button @click="runAllTests()" :disabled="running"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<span x-show="!running">Run All Tests</span>
<span x-show="running">Running...</span>
</button>
<div class="flex gap-2">
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
</div>
</div>
</div>
<!-- Custom test -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template>
</div>
<!-- Test Results -->
<div class="space-y-4">
<template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
@click="test.expanded = !test.expanded">
<div class="flex items-center gap-3">
<!-- Status indicator -->
<div class="w-3 h-3 rounded-full"
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result,
'bg-green-500': test.result && test.pass,
'bg-red-500': test.result && !test.pass,
'animate-pulse bg-yellow-400': test.running
}"></div>
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
}" x-text="test.groupLabel"></span>
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
</div>
</div>
<div class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3">
<template x-if="test.result">
<div x-html="renderTrace(test.result)"></div>
</template>
<template x-if="!test.result && !test.running">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template>
<template x-if="test.running">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
</template>
</div>
</div>
</template>
</div>
<script>
function platformDebug() {
return {
running: false,
filterGroup: 'all',
customHost: 'localhost:8000',
customPath: '/platforms/loyalty/store/WIZATECH/login',
customPlatformCode: '',
customResult: null,
tests: [
// ── Dev path-based ──
{
id: 'dev-1', group: 'dev', groupLabel: 'Dev',
label: '/platforms/oms/store/ACME/login',
description: 'Store login, OMS platform context',
host: 'localhost:8000',
path: '/platforms/oms/store/ACME/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-2', group: 'dev', groupLabel: 'Dev',
label: '/platforms/loyalty/store/ACME/login',
description: 'Store login, Loyalty platform context',
host: 'localhost:8000',
path: '/platforms/loyalty/store/ACME/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-3', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login (no body platform)',
description: 'What middleware sees for the login API call — no platform_code in body',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: null,
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-4', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login + body platform_code=loyalty',
description: 'Login API with loyalty in body (Source 2)',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: 'loyalty',
store_code_body: 'WIZATECH',
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-5', group: 'dev', groupLabel: 'Dev',
label: 'API: /api/v1/store/auth/login + body platform_code=oms',
description: 'Login API with oms in body (Source 2)',
host: 'localhost:8000',
path: '/api/v1/store/auth/login',
platform_code_body: 'oms',
store_code_body: 'WIZATECH',
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-6', group: 'dev', groupLabel: 'Dev',
label: '/store/ACME/login (no /platforms/ prefix)',
description: 'Store login without platform prefix on localhost',
host: 'localhost:8000',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: null,
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-7', group: 'dev', groupLabel: 'Dev',
label: '/platforms/oms/storefront/ACME/account/login',
description: 'Customer login, OMS platform context',
host: 'localhost:8000',
path: '/platforms/oms/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'dev-8', group: 'dev', groupLabel: 'Dev',
label: '/platforms/loyalty/storefront/ACME/account/login',
description: 'Customer login, Loyalty platform context',
host: 'localhost:8000',
path: '/platforms/loyalty/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod domain-based (path-based demo/trial) ──
{
id: 'prod-d-1', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /store/ACME/login',
description: 'OMS platform domain, store login',
host: 'omsflow.lu',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-2', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'rewardflow.lu /store/ACME/login',
description: 'Loyalty platform domain, store login',
host: 'rewardflow.lu',
path: '/store/ACME/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-3', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /api/v1/store/auth/login (API)',
description: 'API call on OMS domain — middleware should detect platform',
host: 'omsflow.lu',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-d-4', group: 'prod-domain', groupLabel: 'Prod Domain',
label: 'omsflow.lu /storefront/ACME/account/login',
description: 'Customer login on OMS domain',
host: 'omsflow.lu',
path: '/storefront/ACME/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod subdomain ──
{
id: 'prod-s-1', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /store/login',
description: 'OMS subdomain, store login',
host: 'acme.omsflow.lu',
path: '/store/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-2', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /account/login',
description: 'OMS subdomain, customer login',
host: 'acme.omsflow.lu',
path: '/account/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-3', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme-rewards.rewardflow.lu /store/login',
description: 'Loyalty subdomain (custom_subdomain), store login',
host: 'acme-rewards.rewardflow.lu',
path: '/store/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-s-4', group: 'prod-subdomain', groupLabel: 'Prod Subdomain',
label: 'acme.omsflow.lu /api/v1/store/auth/login (API)',
description: 'API call on OMS subdomain',
host: 'acme.omsflow.lu',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
// ── Prod custom domain ──
// Uses real StoreDomain records:
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
{
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /store/login',
description: 'Custom domain → WIZATECH, OMS platform (StoreDomain.platform_id=1)',
host: 'wizatech.shop',
path: '/store/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-2', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'fashionhub.store /store/login',
description: 'Custom domain → FASHIONHUB, Loyalty platform (StoreDomain.platform_id=3)',
host: 'fashionhub.store',
path: '/store/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-3', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /api/v1/store/auth/login (API)',
description: 'API call on custom domain — middleware should detect platform',
host: 'wizatech.shop',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'oms',
expanded: false, result: null, pass: null, running: false,
},
{
id: 'prod-c-4', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'fashionhub.store /api/v1/store/auth/login (API)',
description: 'API call on custom domain — middleware should detect platform',
host: 'fashionhub.store',
path: '/api/v1/store/auth/login',
platform_code_body: null,
expect_platform: 'loyalty',
expanded: false, result: null, pass: null, running: false,
},
],
get filteredTests() {
if (this.filterGroup === 'all') return this.tests;
return this.tests.filter(t => t.group === this.filterGroup);
},
async runAllTests() {
this.running = true;
for (const test of this.tests) {
test.running = true;
try {
test.result = await this.traceRequest(test.host, test.path, test.platform_code_body, test.store_code_body);
test.pass = test.expect_platform === null
? true // No expectation
: test.result.login_platform_code === test.expect_platform;
} catch (e) {
test.result = { error: e.message };
test.pass = false;
}
test.running = false;
}
this.running = false;
},
async runCustomTest() {
try {
this.customResult = await this.traceRequest(
this.customHost, this.customPath, this.customPlatformCode || null
);
} catch (e) {
this.customResult = { error: e.message };
}
},
async traceRequest(host, path, platformCodeBody, storeCodeBody) {
let url = `/api/v1/admin/debug/platform-trace?host=${encodeURIComponent(host)}&path=${encodeURIComponent(path)}`;
if (platformCodeBody) url += `&platform_code_body=${encodeURIComponent(platformCodeBody)}`;
if (storeCodeBody) url += `&store_code_body=${encodeURIComponent(storeCodeBody)}`;
const resp = await apiClient.get(url.replace('/api/v1', ''));
return resp;
},
copyTrace(test) {
const r = test.result;
if (!r || r.error) return;
let text = `${test.label}\n${test.description}\n`;
text += `Host: ${r.input_host} Path: ${r.input_path}`;
if (r.input_platform_code_body) text += ` Body platform_code: ${r.input_platform_code_body}`;
text += '\n\n';
for (const step of r.steps) {
text += `${step.step}\n`;
if (step.note) text += `${step.note}\n`;
if (step.result) text += JSON.stringify(step.result, null, 2) + '\n';
text += '\n';
}
navigator.clipboard.writeText(text);
},
copyStepText(step) {
let text = `${step.step}\n`;
if (step.note) text += `${step.note}\n`;
if (step.result) text += JSON.stringify(step.result, null, 2);
navigator.clipboard.writeText(text);
},
renderTrace(result) {
if (result.error) {
return `<div class="text-red-600 dark:text-red-400 text-sm font-mono">${result.error}</div>`;
}
let html = `<div class="text-xs font-mono space-y-1">`;
html += `<div class="mb-2 text-gray-500 dark:text-gray-400">`;
html += `Host: <span class="text-gray-900 dark:text-white">${result.input_host}</span> `;
html += `Path: <span class="text-gray-900 dark:text-white">${result.input_path}</span>`;
if (result.input_platform_code_body) {
html += ` Body platform_code: <span class="text-gray-900 dark:text-white">${result.input_platform_code_body}</span>`;
}
html += `</div>`;
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
const isLast = step.step.startsWith('8.');
const bgClass = isLast
? 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-200 dark:border-indigo-700'
: 'bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700';
html += `<div class="p-2 rounded border ${bgClass}">`;
html += `<div class="flex items-start justify-between">`;
html += `<div class="font-semibold text-gray-700 dark:text-gray-300">${step.step}</div>`;
const stepData = btoa(unescape(encodeURIComponent(
step.step + '\n' + (step.note || '') + '\n' + (step.result ? JSON.stringify(step.result, null, 2) : '')
)));
html += `<button onclick="navigator.clipboard.writeText(decodeURIComponent(escape(atob(this.dataset.text))))" data-text="${stepData}" class="ml-2 shrink-0 px-1.5 py-0.5 text-[10px] bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Copy</button>`;
html += `</div>`;
if (step.note) {
html += `<div class="text-gray-500 dark:text-gray-400 mt-0.5">${step.note}</div>`;
}
if (step.result) {
const jsonStr = JSON.stringify(step.result, null, 2);
html += `<pre class="mt-1 text-green-700 dark:text-green-400 whitespace-pre-wrap">${jsonStr}</pre>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,205 @@
{# app/modules/dev_tools/templates/dev_tools/admin/sql-query.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}SQL Query Tool{% endblock %}
{% block alpine_data %}sqlQueryTool(){% endblock %}
{% block content %}
{{ page_header('SQL Query Tool', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
<div class="flex gap-6">
<!-- Left sidebar -->
<div class="w-72 flex-shrink-0 space-y-4">
<!-- Schema Explorer (preset queries) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<button @click="showPresets = !showPresets"
class="flex items-center justify-between w-full text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
<span class="flex items-center gap-1.5">
<span x-html="$icon('database', 'w-4 h-4')"></span>
Schema Explorer
</span>
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
</button>
<div x-show="showPresets" x-collapse class="mt-3">
<template x-for="group in presetQueries" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<template x-for="preset in group.items" :key="preset.name">
<li @click="loadPreset(preset)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="preset.name"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</div>
<!-- Saved Queries -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<span x-html="$icon('collection', 'w-4 h-4')"></span>
Saved Queries
</h3>
<div x-show="loadingSaved" class="text-sm text-gray-500">Loading...</div>
<div x-show="!loadingSaved && savedQueries.length === 0" class="text-sm text-gray-400">
No saved queries yet.
</div>
<ul class="space-y-1">
<template x-for="q in savedQueries" :key="q.id">
<li class="group flex items-center justify-between rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
:class="activeSavedId === q.id ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'"
@click="loadQuery(q)">
<div class="flex-1 min-w-0">
<div class="truncate font-medium" x-text="q.name"></div>
<div class="text-xs text-gray-400" x-show="q.run_count > 0">
Run <span x-text="q.run_count"></span> time<span x-show="q.run_count !== 1">s</span>
</div>
</div>
<button @click.stop="deleteSavedQuery(q.id)"
class="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-500 transition-opacity"
title="Delete">
<span x-html="$icon('trash', 'w-4 h-4')"></span>
</button>
</li>
</template>
</ul>
</div>
</div>
<!-- Main area -->
<div class="flex-1 min-w-0">
<!-- SQL Editor -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-4">
<div class="p-4">
<textarea
x-model="sql"
rows="8"
class="w-full bg-gray-900 text-green-400 font-mono text-sm rounded-lg p-4 border border-gray-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 resize-y"
placeholder="Enter your SQL query here... (SELECT only)"
spellcheck="false"
></textarea>
</div>
<!-- Action bar -->
<div class="flex items-center gap-3 px-4 pb-4">
<button @click="executeQuery()"
:disabled="running || !sql.trim()"
class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-show="!running" x-html="$icon('play', 'w-4 h-4 mr-1.5')"></span>
<span x-show="running" x-html="$icon('spinner', 'w-4 h-4 mr-1.5')"></span>
<span x-text="running ? 'Running...' : 'Run Query'"></span>
<span class="ml-1.5 text-xs opacity-70">(Ctrl+Enter)</span>
</button>
<button @click="openSaveModal()"
:disabled="!sql.trim()"
class="inline-flex items-center px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<span x-html="$icon('save', 'w-4 h-4 mr-1.5')"></span>
Save Query
</button>
<button @click="exportCsv()"
x-show="rows.length > 0"
class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
<span x-html="$icon('download', 'w-4 h-4 mr-1.5')"></span>
Export CSV
</button>
</div>
</div>
<!-- Execution info -->
<div x-show="executionTimeMs !== null" class="mb-4 text-sm text-gray-600 dark:text-gray-400 flex items-center gap-4">
<span>
<span x-text="rowCount"></span> row<span x-show="rowCount !== 1">s</span> returned
</span>
<span x-show="truncated" class="text-amber-600 dark:text-amber-400 font-medium">
(results truncated to 1000 rows)
</span>
<span>
<span x-text="executionTimeMs"></span> ms
</span>
</div>
<!-- Error display -->
<div x-show="error" class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex items-start">
<span x-html="$icon('exclamation-circle', 'w-5 h-5 text-red-500 mr-2 flex-shrink-0')"></span>
<pre class="text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words" x-text="error"></pre>
</div>
</div>
<!-- Results table -->
<div x-show="columns.length > 0" class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<template x-for="col in columns" :key="col">
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
x-text="col"></th>
</template>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="(row, rowIdx) in rows" :key="rowIdx">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
<template x-for="(cell, cellIdx) in row" :key="cellIdx">
<td class="px-4 py-2 text-sm font-mono whitespace-nowrap max-w-xs truncate"
:class="isNull(cell) ? 'text-gray-400 italic' : 'text-gray-900 dark:text-gray-100'"
:title="formatCell(cell)"
x-text="formatCell(cell)"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Save Query Modal -->
{% call modal('saveQueryModal', 'Save Query', show_var='showSaveModal', size='sm', show_footer=false) %}
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input type="text" x-model="saveName"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="e.g. Active users count"
@keydown.enter="saveQuery()">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description (optional)</label>
<input type="text" x-model="saveDescription"
class="w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Brief description of what this query does">
</div>
<div class="flex justify-end gap-3 pt-2">
<button @click="showSaveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Cancel
</button>
<button @click="saveQuery()"
:disabled="!saveName.trim() || saving"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
<span x-text="saving ? 'Saving...' : 'Save'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
{% endblock %}