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:
@@ -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}
|
||||
Reference in New Issue
Block a user