feat(loyalty): wallet debug page, Google Wallet fixes, and module config env_file standardization
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 32s
CI / pytest (push) Failing after 1h13m39s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only)
  with explorer-sidebar pattern: config validation, class status, card inspector,
  save URL tester, recent enrollments, and Apple Wallet status panels
- Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in
  payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus
- Fix StorefrontProgramResponse schema: accept google_class_id values while
  keeping exclude=True (was rejecting non-None values)
- Standardize all module configs to read from .env file directly
  (env_file=".env", extra="ignore") matching core Settings pattern
- Add MOD-026 architecture rule enforcing env_file in module configs
- Add SVC-005 noqa support in architecture validator
- Add test files for dev_tools domain_health and isolation_audit services
- Add google_wallet_status.py script for querying Google Wallet API
- Use table_wrapper macro in wallet-debug.html (FE-005 compliance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:18:39 +01:00
parent 11b8e31a29
commit f89c0382f0
31 changed files with 1721 additions and 64 deletions

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Query Google Wallet API for class and object status.
Usage:
python scripts/google_wallet_status.py # List all classes
python scripts/google_wallet_status.py --class-id ID # Check specific class
python scripts/google_wallet_status.py --object-id ID # Check specific object
python scripts/google_wallet_status.py --fix-draft # Patch DRAFT classes to UNDER_REVIEW
"""
import argparse
import json
import sys
from dotenv import load_dotenv
load_dotenv()
from app.modules.loyalty.config import config
from app.modules.loyalty.services.google_wallet_service import GoogleWalletService
def main():
parser = argparse.ArgumentParser(description="Query Google Wallet API")
parser.add_argument("--class-id", help="Check a specific class ID")
parser.add_argument("--object-id", help="Check a specific object ID")
parser.add_argument(
"--fix-draft",
action="store_true",
help="Patch all DRAFT classes to UNDER_REVIEW for approval",
)
args = parser.parse_args()
svc = GoogleWalletService()
if not svc.is_configured:
print("Google Wallet is not configured.")
print(f" LOYALTY_GOOGLE_ISSUER_ID: {config.google_issuer_id or 'NOT SET'}")
print(f" LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON: {config.google_service_account_json or 'NOT SET'}")
sys.exit(1)
http = svc._get_http_client()
issuer_id = config.google_issuer_id
print(f"Issuer ID: {issuer_id}")
print()
if args.object_id:
_check_object(http, args.object_id)
return
if args.class_id:
_check_class(http, args.class_id)
return
# List all classes for this issuer
print("=== Loyalty Classes ===")
response = http.get(
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass",
params={"issuerId": issuer_id},
)
if response.status_code != 200:
print(f"Error: {response.status_code} - {response.text}")
sys.exit(1)
data = response.json()
classes = data.get("resources", [])
if not classes:
print("No loyalty classes found.")
return
for cls in classes:
status = cls.get("reviewStatus", "UNKNOWN")
class_id = cls.get("id")
program_name = cls.get("programName", "")
issuer_name = cls.get("issuerName", "")
color = cls.get("hexBackgroundColor", "")
status_icon = {
"approved": "",
"draft": "⚠️",
"rejected": "",
"underReview": "🔄",
}.get(status, "")
print(f" {status_icon} {class_id}")
print(f" Program: {program_name}")
print(f" Issuer: {issuer_name}")
print(f" Status: {status}")
print(f" Color: {color}")
print()
if args.fix_draft and status == "draft":
print(" Patching to UNDER_REVIEW...")
patch_resp = http.patch(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{class_id}",
json={"reviewStatus": "UNDER_REVIEW"},
)
if patch_resp.status_code == 200:
new_status = patch_resp.json().get("reviewStatus", "unknown")
print(f" ✅ Updated! New status: {new_status}")
else:
print(f" ❌ Failed: {patch_resp.status_code} - {patch_resp.text}")
print()
def _check_class(http, class_id):
print(f"=== Class: {class_id} ===")
response = http.get(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{class_id}",
)
if response.status_code == 200:
print(json.dumps(response.json(), indent=2))
elif response.status_code == 404:
print("Class not found.")
else:
print(f"Error: {response.status_code} - {response.text}")
def _check_object(http, object_id):
print(f"=== Object: {object_id} ===")
response = http.get(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{object_id}",
)
if response.status_code == 200:
print(json.dumps(response.json(), indent=2))
elif response.status_code == 404:
print("Object not found.")
else:
print(f"Error: {response.status_code} - {response.text}")
if __name__ == "__main__":
main()

View File

@@ -2236,6 +2236,9 @@ class ArchitectureValidator:
for i, line in enumerate(lines, 1):
# Check for .all() queries that might not be scoped
if ".query(" in line and ".all()" in line:
# Skip if noqa comment present
if "noqa: SVC-005" in line or "noqa: SVC005" in line:
continue
# Check context for store filtering
context_start = max(0, i - 5)
context_end = min(len(lines), i + 3)
@@ -4112,7 +4115,7 @@ class ArchitectureValidator:
)
def _validate_modules(self, target_path: Path):
"""Validate module structure rules (MOD-001 to MOD-012)"""
"""Validate module structure rules (MOD-001 to MOD-012, MOD-026)"""
print("📦 Validating module structure...")
modules_path = target_path / "app" / "modules"
@@ -4353,6 +4356,47 @@ class ArchitectureValidator:
suggestion=f"Add translation files: {', '.join(missing_langs)}",
)
# MOD-026: Check config.py uses env_file for .env loading
config_file = module_dir / "config.py"
if config_file.exists():
config_content = config_file.read_text()
if "model_config" in config_content:
if '"env_file"' not in config_content and "'env_file'" not in config_content:
# Find the model_config line number
config_line = next(
(i for i, line in enumerate(config_content.split("\n"), 1) if "model_config" in line),
1,
)
self._add_violation(
rule_id="MOD-026",
rule_name="Module config must read from .env file",
severity=Severity.ERROR,
file_path=config_file,
line_number=config_line,
message=f"Module '{module_name}' config has model_config without env_file=\".env\""
"settings won't load from .env when running outside Docker",
context="model_config missing env_file",
suggestion='Add env_file and extra to model_config: '
'{"env_prefix": "...", "env_file": ".env", "extra": "ignore"}',
)
if '"extra"' not in config_content and "'extra'" not in config_content:
config_line = next(
(i for i, line in enumerate(config_content.split("\n"), 1) if "model_config" in line),
1,
)
self._add_violation(
rule_id="MOD-026",
rule_name="Module config must ignore extra .env fields",
severity=Severity.ERROR,
file_path=config_file,
line_number=config_line,
message=f"Module '{module_name}' config has model_config without extra=\"ignore\""
"will fail when .env contains vars for other modules",
context="model_config missing extra=ignore",
suggestion='Add extra to model_config: '
'{"env_prefix": "...", "env_file": ".env", "extra": "ignore"}',
)
# MOD-007: Validate definition paths match directory structure
self._validate_module_definition_paths(module_dir, module_name, definition_file, definition_content)