feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
34
app/modules/dev_tools/models/saved_query.py
Normal file
34
app/modules/dev_tools/models/saved_query.py
Normal 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,
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
17
app/modules/dev_tools/routes/api/admin.py
Normal file
17
app/modules/dev_tools/routes/api/admin.py
Normal 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"])
|
||||
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal file
344
app/modules/dev_tools/routes/api/admin_platform_debug.py
Normal 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("_")}
|
||||
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal file
158
app/modules/dev_tools/routes/api/admin_sql_query.py
Normal 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}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
197
app/modules/dev_tools/services/sql_query_service.py
Normal file
197
app/modules/dev_tools/services/sql_query_service.py
Normal 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()
|
||||
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal file
249
app/modules/dev_tools/static/admin/js/sql-query.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 %}
|
||||
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal file
205
app/modules/dev_tools/templates/dev_tools/admin/sql-query.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user