feat(subscriptions): migrate subscription management to merchant level and seed tiers

Move subscription create/edit from store detail (broken endpoint) to merchant
detail page with proper modal UI. Seed 4 subscription tiers (Essential,
Professional, Business, Enterprise) in init_production.py. Also includes
cross-module dependency declarations, store domain platform_id migration,
platform context middleware, CMS route fixes, and migration backups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 21:04:04 +01:00
parent 7feacd5af8
commit 68493dc6cb
97 changed files with 13286 additions and 77 deletions

View File

@@ -9,9 +9,11 @@ Usage:
python scripts/show_urls.py # Show all URLs
python scripts/show_urls.py --dev # Development URLs only
python scripts/show_urls.py --prod # Production URLs only
python scripts/show_urls.py --check # Check dev URLs with curl
"""
import argparse
import subprocess
import sys
from sqlalchemy import text
@@ -60,11 +62,107 @@ def get_store_domains(db):
).fetchall()
def get_store_platform_map(db):
"""
Get store-to-platform mapping.
Returns dict: store_id -> list of platform codes.
Uses store_platforms junction table.
"""
rows = db.execute(
text(
"SELECT sp.store_id, p.code AS platform_code "
"FROM store_platforms sp "
"JOIN platforms p ON p.id = sp.platform_id "
"WHERE sp.is_active = true "
"ORDER BY sp.store_id, sp.is_primary DESC"
)
).fetchall()
mapping = {}
for r in rows:
mapping.setdefault(r.store_id, []).append(r.platform_code)
return mapping
def status_badge(is_active):
return "active" if is_active else "INACTIVE"
def print_dev_urls(platforms, stores, store_domains):
def _store_dev_dashboard_url(store_code, platform_code=None):
"""Build store dashboard URL for dev mode."""
if platform_code and platform_code != "main":
return f"{DEV_BASE}/platforms/{platform_code}/store/{store_code}/"
return f"{DEV_BASE}/store/{store_code}/"
def _store_dev_login_url(store_code, platform_code=None):
"""Build store login URL for dev mode."""
if platform_code and platform_code != "main":
return f"{DEV_BASE}/platforms/{platform_code}/store/{store_code}/login"
return f"{DEV_BASE}/store/{store_code}/login"
def collect_dev_urls(platforms, stores, store_domains, store_platform_map):
"""
Collect all dev URLs with labels and expected status codes.
Returns list of (label, url, expected_codes) tuples.
"""
urls = []
# Admin
urls.append(("Admin Login", f"{DEV_BASE}/admin/login", [200]))
urls.append(("Admin Dashboard", f"{DEV_BASE}/admin/", [200, 302]))
urls.append(("API Docs", f"{DEV_BASE}/docs", [200]))
urls.append(("Health", f"{DEV_BASE}/health", [200]))
# Platforms
for p in platforms:
if not p.is_active:
continue
if p.code == "main":
urls.append((f"Platform: {p.name}", f"{DEV_BASE}/", [200]))
else:
urls.append((f"Platform: {p.name}", f"{DEV_BASE}/platforms/{p.code}/", [200]))
# Store dashboards (redirect to login when unauthenticated)
for v in stores:
if not v.is_active:
continue
platform_codes = store_platform_map.get(v.id, [])
if platform_codes:
for pc in platform_codes:
url = _store_dev_login_url(v.store_code, pc)
urls.append((f"Store Login: {v.name} ({pc})", url, [200]))
else:
url = _store_dev_login_url(v.store_code)
urls.append((f"Store Login: {v.name}", url, [200]))
# Storefronts
for v in stores:
if not v.is_active:
continue
urls.append((
f"Storefront: {v.name}",
f"{DEV_BASE}/stores/{v.store_code}/storefront/",
[200, 302],
))
# Store info API (public, no auth needed)
for v in stores:
if not v.is_active:
continue
urls.append((
f"Store API: {v.name}",
f"{DEV_BASE}/api/v1/store/info/{v.store_code}",
[200],
))
return urls
def print_dev_urls(platforms, stores, store_domains, store_platform_map):
"""Print all development URLs."""
print()
print("DEVELOPMENT URLS")
@@ -84,24 +182,16 @@ def print_dev_urls(platforms, stores, store_domains):
print(" PLATFORMS")
for p in platforms:
tag = f" [{status_badge(p.is_active)}]" if not p.is_active else ""
prefix = p.path_prefix or ""
if p.code == "main":
print(f" {p.name}{tag}")
print(f" Home: {DEV_BASE}/")
else:
print(f" {p.name} ({p.code}){tag}")
if prefix:
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
else:
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
print(f" Home: {DEV_BASE}/platforms/{p.code}/")
# Stores
print()
print(" STORE DASHBOARDS")
domains_by_store = {}
for vd in store_domains:
domains_by_store.setdefault(vd.store_id, []).append(vd)
current_merchant = None
for v in stores:
if v.merchant_name != current_merchant:
@@ -110,9 +200,19 @@ def print_dev_urls(platforms, stores, store_domains):
tag = f" [{status_badge(v.is_active)}]" if not v.is_active else ""
code = v.store_code
# Get platform(s) for this store
platform_codes = store_platform_map.get(v.id, [])
print(f" {v.name} ({code}){tag}")
print(f" Dashboard: {DEV_BASE}/store/{code}/")
print(f" API: {DEV_BASE}/api/v1/store/{code}/")
if platform_codes:
for pc in platform_codes:
print(f" Login: {_store_dev_login_url(code, pc)}")
print(f" Dashboard: {_store_dev_dashboard_url(code, pc)}")
else:
print(f" Login: {_store_dev_login_url(code)}")
print(f" Dashboard: {_store_dev_dashboard_url(code)}")
print(f" (!) No platform assigned - use /platforms/{{code}}/store/{code}/ for platform context")
# Storefronts
print()
@@ -208,20 +308,81 @@ def print_prod_urls(platforms, stores, store_domains):
print(f" Custom: https://{vd.domain}/{suffix}")
def check_dev_urls(platforms, stores, store_domains, store_platform_map):
"""Curl all dev URLs and report reachability."""
urls = collect_dev_urls(platforms, stores, store_domains, store_platform_map)
print()
print("URL HEALTH CHECK")
print(f"Base: {DEV_BASE}")
print(SEPARATOR)
print()
passed = 0
failed = 0
errors = []
for label, url, expected_codes in urls:
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-L", "--max-time", "5", url],
capture_output=True, text=True, timeout=10,
)
status = int(result.stdout.strip())
if status in expected_codes:
print(f" PASS {status} {label}")
print(f" {url}")
passed += 1
else:
expected_str = "/".join(str(c) for c in expected_codes)
print(f" FAIL {status} {label} (expected {expected_str})")
print(f" {url}")
failed += 1
errors.append((label, url, status, expected_codes))
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e:
print(f" ERR --- {label} ({e})")
print(f" {url}")
failed += 1
errors.append((label, url, 0, expected_codes))
# Summary
print()
print(SEPARATOR)
total = passed + failed
print(f" Results: {passed}/{total} passed", end="")
if failed:
print(f", {failed} failed")
else:
print()
if errors:
print()
print(" Failed URLs:")
for label, url, status, expected in errors:
expected_str = "/".join(str(c) for c in expected)
print(f" [{status or 'ERR'}] {url} (expected {expected_str})")
print()
return failed == 0
def main():
parser = argparse.ArgumentParser(description="Show all platform URLs")
parser.add_argument("--dev", action="store_true", help="Development URLs only")
parser.add_argument("--prod", action="store_true", help="Production URLs only")
parser.add_argument("--check", action="store_true", help="Check dev URLs with curl")
args = parser.parse_args()
show_dev = args.dev or (not args.dev and not args.prod)
show_prod = args.prod or (not args.dev and not args.prod)
show_dev = args.dev or (not args.dev and not args.prod and not args.check)
show_prod = args.prod or (not args.dev and not args.prod and not args.check)
db = SessionLocal()
try:
platforms = get_platforms(db)
stores = get_stores(db)
store_domains = get_store_domains(db)
store_platform_map = get_store_platform_map(db)
except Exception as e:
print(f"Error querying database: {e}", file=sys.stderr)
sys.exit(1)
@@ -234,8 +395,12 @@ def main():
print(f" {len(platforms)} platform(s), {len(stores)} store(s), {len(store_domains)} custom domain(s)")
print("=" * 72)
if args.check:
success = check_dev_urls(platforms, stores, store_domains, store_platform_map)
sys.exit(0 if success else 1)
if show_dev:
print_dev_urls(platforms, stores, store_domains)
print_dev_urls(platforms, stores, store_domains, store_platform_map)
if show_prod:
print_prod_urls(platforms, stores, store_domains)