Jun 28 - Update and polish UI/UX
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
control/backup.py
|
||||
-----------------
|
||||
Per-tenant MySQL database backup tool.
|
||||
|
||||
Parses each tenant's db_uri and runs mysqldump via subprocess, writing a
|
||||
gzip-compressed SQL dump to <output_dir>/<slug>_<YYYYMMDD_HHMMSS>.sql.gz.
|
||||
|
||||
Usage:
|
||||
python -m control.backup --tenant all --output-dir /backups
|
||||
python -m control.backup --tenant acme --output-dir /backups
|
||||
python -m control.backup --list
|
||||
|
||||
Requirements:
|
||||
mysqldump binary on PATH.
|
||||
Environment: source /etc/jqc/control.env first (needs CONTROL_DATABASE_URL).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_db_uri(uri: str) -> dict:
|
||||
"""Return host, port, user, password, database from a SQLAlchemy DB URI."""
|
||||
p = urlparse(uri)
|
||||
return {
|
||||
'host': p.hostname or '127.0.0.1',
|
||||
'port': str(p.port or 3306),
|
||||
'user': p.username or '',
|
||||
'password': p.password or '',
|
||||
'database': p.path.lstrip('/'),
|
||||
}
|
||||
|
||||
|
||||
def backup_tenant(slug: str, db_uri: str, output_dir: str) -> str:
|
||||
"""Run mysqldump for one tenant and save as a .sql.gz file.
|
||||
|
||||
Returns the full path to the written file.
|
||||
Raises RuntimeError on failure.
|
||||
"""
|
||||
if not shutil.which('mysqldump'):
|
||||
raise RuntimeError('mysqldump not found on PATH')
|
||||
|
||||
db = _parse_db_uri(db_uri)
|
||||
if not db['database']:
|
||||
raise RuntimeError(f'Could not determine database name from URI: {db_uri}')
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'{slug}_{timestamp}.sql.gz'
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
cmd = [
|
||||
'mysqldump',
|
||||
f'--host={db["host"]}',
|
||||
f'--port={db["port"]}',
|
||||
f'--user={db["user"]}',
|
||||
f'--password={db["password"]}',
|
||||
'--single-transaction',
|
||||
'--routines',
|
||||
'--triggers',
|
||||
'--set-gtid-purged=OFF',
|
||||
db['database'],
|
||||
]
|
||||
|
||||
logger.info('Backing up tenant=%s db=%s → %s', slug, db['database'], filepath)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f'mysqldump timed out for tenant {slug}')
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode(errors='replace').strip()
|
||||
# mysqldump prints warnings to stderr even on success; only fail on non-zero exit.
|
||||
raise RuntimeError(
|
||||
f'mysqldump exited {result.returncode} for tenant {slug}:\n{stderr}'
|
||||
)
|
||||
|
||||
with gzip.open(filepath, 'wb') as gz:
|
||||
gz.write(result.stdout)
|
||||
|
||||
size_kb = os.path.getsize(filepath) // 1024
|
||||
logger.info(' ✓ %s written (%d KB)', filepath, size_kb)
|
||||
return filepath
|
||||
|
||||
|
||||
def _select_tenants(slug_or_all: str):
|
||||
"""Return list of (slug, db_uri) tuples from the control DB."""
|
||||
from control.base import control_session
|
||||
from control.models import Tenant
|
||||
|
||||
with control_session() as s:
|
||||
if slug_or_all == 'all':
|
||||
tenants = s.query(Tenant).filter(
|
||||
Tenant.status != 'deleted'
|
||||
).order_by(Tenant.id).all()
|
||||
else:
|
||||
tenants = s.query(Tenant).filter_by(slug=slug_or_all).all()
|
||||
if not tenants:
|
||||
raise ValueError(f'Tenant "{slug_or_all}" not found in control DB.')
|
||||
return [(t.slug, t.db_uri) for t in tenants]
|
||||
|
||||
|
||||
def _cmd_backup(args):
|
||||
try:
|
||||
tenants = _select_tenants(args.tenant)
|
||||
except ValueError as exc:
|
||||
logger.error('%s', exc)
|
||||
sys.exit(1)
|
||||
|
||||
output_dir = os.path.expanduser(args.output_dir)
|
||||
ok = 0
|
||||
failed = 0
|
||||
|
||||
for slug, db_uri in tenants:
|
||||
try:
|
||||
path = backup_tenant(slug, db_uri, output_dir)
|
||||
ok += 1
|
||||
except Exception as exc:
|
||||
logger.error('FAILED tenant=%s: %s', slug, exc)
|
||||
failed += 1
|
||||
|
||||
print(f'\nBackup complete: {ok} succeeded, {failed} failed.')
|
||||
print(f'Files written to: {output_dir}')
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _cmd_list(args):
|
||||
"""List tenants in the control DB."""
|
||||
from control.base import control_session
|
||||
from control.models import Tenant
|
||||
|
||||
with control_session() as s:
|
||||
tenants = s.query(Tenant).order_by(Tenant.id).all()
|
||||
print(f'{"ID":<5} {"Slug":<20} {"Status":<12} {"DB Name":<30}')
|
||||
print('─' * 70)
|
||||
for t in tenants:
|
||||
db_name = _parse_db_uri(t.db_uri).get('database', '?')
|
||||
print(f'{t.id:<5} {t.slug:<20} {t.status:<12} {db_name:<30}')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='JQC per-tenant MySQL backup tool',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
sub = parser.add_subparsers(dest='command')
|
||||
|
||||
p_backup = sub.add_parser('backup', help='Run mysqldump for one or all tenants')
|
||||
p_backup.add_argument('--tenant', required=True,
|
||||
help='Tenant slug or "all"')
|
||||
p_backup.add_argument('--output-dir', default='/var/backups/jqc',
|
||||
help='Directory for dump files (default: /var/backups/jqc)')
|
||||
p_backup.set_defaults(func=_cmd_backup)
|
||||
|
||||
p_list = sub.add_parser('list', help='List tenant slugs and DB names')
|
||||
p_list.set_defaults(func=_cmd_list)
|
||||
|
||||
# Allow calling without a subcommand when --tenant is given (convenience).
|
||||
parser.add_argument('--tenant', help='Tenant slug or "all" (shorthand — no subcommand needed)')
|
||||
parser.add_argument('--output-dir', default='/var/backups/jqc',
|
||||
help='Output directory for dump files')
|
||||
parser.add_argument('--list', action='store_true',
|
||||
help='List tenants (shorthand)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command:
|
||||
args.func(args)
|
||||
elif getattr(args, 'list', False):
|
||||
_cmd_list(args)
|
||||
elif getattr(args, 'tenant', None):
|
||||
_cmd_backup(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -28,6 +28,7 @@ from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
from .auth import bp as auth_bp
|
||||
from .tenants import bp as tenants_bp
|
||||
from .health import bp as health_bp
|
||||
|
||||
csrf = CSRFProtect()
|
||||
|
||||
@@ -83,6 +84,7 @@ def create_panel_app():
|
||||
# ── Blueprints ────────────────────────────────────────────────────────
|
||||
app.register_blueprint(auth_bp) # /login, /logout
|
||||
app.register_blueprint(tenants_bp) # /tenants/…
|
||||
app.register_blueprint(health_bp) # /health/
|
||||
|
||||
# ── Root redirect ─────────────────────────────────────────────────────
|
||||
from flask import redirect, url_for
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
control/panel/health.py
|
||||
-----------------------
|
||||
Superadmin health / monitoring dashboard (MT-8+).
|
||||
|
||||
GET /health — reads control plane data only (no per-tenant DB queries)
|
||||
so it stays fast regardless of tenant count.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from flask import Blueprint, render_template, session
|
||||
|
||||
from control.base import control_session
|
||||
from control.models import Plan, Tenant
|
||||
from control.tenant_migrate import chain_head
|
||||
from control.time_utils import now_eastern
|
||||
from .decorators import superadmin_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('health', __name__, url_prefix='/health')
|
||||
|
||||
_BASE_DOMAIN = lambda: os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@superadmin_required
|
||||
def dashboard():
|
||||
try:
|
||||
head = chain_head()
|
||||
except Exception:
|
||||
head = None
|
||||
|
||||
with control_session() as s:
|
||||
plans = {p.id: p.name for p in s.query(Plan).all()}
|
||||
tenants = s.query(Tenant).order_by(Tenant.id).all()
|
||||
|
||||
now = now_eastern()
|
||||
rows = []
|
||||
for t in tenants:
|
||||
trial_days_left = None
|
||||
trial_expired = False
|
||||
if t.trial_ends_at:
|
||||
delta = (t.trial_ends_at - now).days
|
||||
trial_days_left = max(0, delta)
|
||||
trial_expired = now >= t.trial_ends_at
|
||||
|
||||
schema_ok = (t.alembic_head == head) if head else None
|
||||
|
||||
rows.append({
|
||||
'id': t.id,
|
||||
'slug': t.slug,
|
||||
'name': t.name,
|
||||
'status': t.status,
|
||||
'plan_name': plans.get(t.plan_id, '?'),
|
||||
'subscription_status': t.subscription_status,
|
||||
'trial_ends_at': t.trial_ends_at,
|
||||
'trial_days_left': trial_days_left,
|
||||
'trial_expired': trial_expired,
|
||||
'current_period_end': t.current_period_end,
|
||||
'has_stripe': bool(t.stripe_customer_id),
|
||||
'alembic_head': t.alembic_head,
|
||||
'schema_ok': schema_ok,
|
||||
'created_at': t.created_at,
|
||||
'suspended_at': t.suspended_at,
|
||||
})
|
||||
|
||||
# ── Aggregate stats ──────────────────────────────────────────────────────
|
||||
total = len(rows)
|
||||
active = sum(1 for r in rows if r['status'] == 'active')
|
||||
suspended = sum(1 for r in rows if r['status'] == 'suspended')
|
||||
schema_behind = sum(1 for r in rows if r['schema_ok'] is False)
|
||||
|
||||
sub_counts = {}
|
||||
for r in rows:
|
||||
key = r['subscription_status'] or 'none'
|
||||
sub_counts[key] = sub_counts.get(key, 0) + 1
|
||||
|
||||
trial_expiring_soon = sum(
|
||||
1 for r in rows
|
||||
if r['subscription_status'] == 'trial'
|
||||
and r['trial_days_left'] is not None
|
||||
and r['trial_days_left'] <= 3
|
||||
and not r['trial_expired']
|
||||
)
|
||||
trial_expired_count = sum(
|
||||
1 for r in rows
|
||||
if r['subscription_status'] == 'trial' and r['trial_expired']
|
||||
)
|
||||
|
||||
stats = {
|
||||
'total': total,
|
||||
'active': active,
|
||||
'suspended': suspended,
|
||||
'schema_behind': schema_behind,
|
||||
'sub_counts': sub_counts,
|
||||
'trial_expiring_soon': trial_expiring_soon,
|
||||
'trial_expired': trial_expired_count,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
'panel/health.html',
|
||||
rows=rows,
|
||||
stats=stats,
|
||||
chain_head=head,
|
||||
sa_username=session.get('sa_username'),
|
||||
base_domain=_BASE_DOMAIN(),
|
||||
)
|
||||
@@ -80,6 +80,10 @@
|
||||
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
|
||||
<i class="bi bi-plus-circle"></i> New Tenant
|
||||
</a>
|
||||
<a href="{{ url_for('health.dashboard') }}"
|
||||
class="{{ 'active' if request.endpoint == 'health.dashboard' else '' }}">
|
||||
<i class="bi bi-heart-pulse"></i> Health
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sa-footer">
|
||||
{% if sa_username %}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{% extends "panel/base.html" %}
|
||||
{% block title %}Health — JQC Control{% endblock %}
|
||||
{% block page_title %}System Health{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.sub-badge-trial { background:#dbeafe;color:#1d4ed8; }
|
||||
.sub-badge-active { background:#dcfce7;color:#15803d; }
|
||||
.sub-badge-past_due { background:#fef3c7;color:#92400e; }
|
||||
.sub-badge-cancelled { background:#fee2e2;color:#991b1b; }
|
||||
.sub-badge-none { background:#f1f5f9;color:#64748b; }
|
||||
.sub-badge { display:inline-block;padding:.18rem .55rem;border-radius:12px;font-size:.7rem;font-weight:600; }
|
||||
.stat-card { background:#fff;border-radius:8px;padding:1rem 1.25rem;box-shadow:0 1px 3px rgba(0,0,0,.06); }
|
||||
.stat-num { font-size:1.8rem;font-weight:700;line-height:1; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ── Summary stat cards ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Total Tenants</div>
|
||||
<div class="stat-num mt-1">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Active</div>
|
||||
<div class="stat-num mt-1 text-success">{{ stats.active }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Suspended</div>
|
||||
<div class="stat-num mt-1 {{ 'text-warning' if stats.suspended else '' }}">{{ stats.suspended }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="text-muted" style="font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;">Schema Behind</div>
|
||||
<div class="stat-num mt-1 {{ 'text-danger' if stats.schema_behind else '' }}">{{ stats.schema_behind }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Subscription breakdown + trial alerts ── #}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Subscription Breakdown</span>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for key, count in stats.sub_counts.items() %}
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="sub-badge sub-badge-{{ key }}">{{ key }}</span>
|
||||
<span class="fw-semibold">{{ count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-2">
|
||||
<span class="fw-semibold" style="font-size:.9rem;">Trial Alerts</span>
|
||||
</div>
|
||||
<div class="card-body py-2 d-flex flex-column gap-2" style="font-size:.85rem;">
|
||||
{% if stats.trial_expired %}
|
||||
<div class="d-flex align-items-center gap-2 text-danger">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
<span><strong>{{ stats.trial_expired }}</strong> trial{{ 's' if stats.trial_expired != 1 }} expired — workspace locked</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if stats.trial_expiring_soon %}
|
||||
<div class="d-flex align-items-center gap-2 text-warning">
|
||||
<i class="bi bi-hourglass-split"></i>
|
||||
<span><strong>{{ stats.trial_expiring_soon }}</strong> trial{{ 's' if stats.trial_expiring_soon != 1 }} expiring within 3 days</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not stats.trial_expired and not stats.trial_expiring_soon %}
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>All trials healthy.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Tenant table ── #}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-table me-1"></i>All Tenants</span>
|
||||
<span class="text-muted" style="font-size:.78rem;">
|
||||
Schema HEAD: <code>{{ chain_head or '?' }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" style="font-size:.82rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Slug / Name</th>
|
||||
<th>Status</th>
|
||||
<th>Plan</th>
|
||||
<th>Subscription</th>
|
||||
<th>Trial / Period</th>
|
||||
<th>Stripe</th>
|
||||
<th>Schema</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr class="{{ 'table-warning' if r.status == 'suspended' else
|
||||
('table-danger' if r.trial_expired and r.subscription_status == 'trial' else '') }}">
|
||||
<td class="text-muted">{{ r.id }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ r.slug }}</div>
|
||||
<div class="text-muted" style="font-size:.75rem;">{{ r.name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ r.status }}">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.plan_name }}</td>
|
||||
<td>
|
||||
<span class="sub-badge sub-badge-{{ r.subscription_status or 'none' }}">
|
||||
{{ r.subscription_status or '—' }}
|
||||
</span>
|
||||
{% if r.subscription_status == 'past_due' %}
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning ms-1"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.subscription_status == 'trial' and r.trial_ends_at %}
|
||||
{% if r.trial_expired %}
|
||||
<span class="text-danger fw-semibold">
|
||||
<i class="bi bi-x-circle me-1"></i>Expired
|
||||
</span>
|
||||
{% elif r.trial_days_left <= 3 %}
|
||||
<span class="text-warning fw-semibold">
|
||||
{{ r.trial_days_left }}d left
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ r.trial_ends_at.strftime('%Y-%m-%d') }}</span>
|
||||
{% endif %}
|
||||
{% elif r.current_period_end %}
|
||||
<span class="text-muted">{{ r.current_period_end.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.has_stripe %}
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-monospace" style="font-size:.72rem;">
|
||||
{% if r.schema_ok is none %}
|
||||
<span class="text-muted">?</span>
|
||||
{% elif r.schema_ok %}
|
||||
<span class="rev-ok"><i class="bi bi-check-circle-fill"></i></span>
|
||||
{% else %}
|
||||
<span class="rev-behind">
|
||||
<i class="bi bi-exclamation-circle-fill me-1"></i>{{ (r.alembic_head or '?')[:10] }}…
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="color:#64748b;">
|
||||
{{ r.created_at.strftime('%Y-%m-%d') if r.created_at else '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('tenants.tenant_detail', tenant_id=r.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm" style="font-size:.72rem;padding:.2rem .5rem;">
|
||||
Detail
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="text-center text-muted py-4">No tenants.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted mt-2" style="font-size:.75rem; text-align:right;">
|
||||
Refreshes on page load only. Schema status read from control DB cache (run Detail → Upgrade to live-check).
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -286,14 +286,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Billing (MT-8, read-only) ── #}
|
||||
{# ── Billing (MT-8) ── #}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
|
||||
</div>
|
||||
<div class="card-body py-2" style="font-size:.83rem;">
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Subscription</span>
|
||||
|
||||
{# Read-only info #}
|
||||
<div class="mb-2">
|
||||
<span class="text-muted">Status</span>
|
||||
<div>
|
||||
{% if tenant.subscription_status == 'active' %}
|
||||
<span class="badge bg-success">active</span>
|
||||
@@ -309,44 +311,80 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if tenant.trial_ends_at %}
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Trial ends</span>
|
||||
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div>
|
||||
</div>
|
||||
<div class="mb-1"><span class="text-muted">Trial ends</span>
|
||||
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div></div>
|
||||
{% endif %}
|
||||
{% if tenant.current_period_end %}
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Period ends</span>
|
||||
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div>
|
||||
</div>
|
||||
<div class="mb-1"><span class="text-muted">Period ends</span>
|
||||
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div></div>
|
||||
{% endif %}
|
||||
{% if tenant.billing_email %}
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Billing email</span>
|
||||
<div>{{ tenant.billing_email }}</div>
|
||||
</div>
|
||||
<div class="mb-1"><span class="text-muted">Billing email</span>
|
||||
<div>{{ tenant.billing_email }}</div></div>
|
||||
{% endif %}
|
||||
{% if tenant.stripe_customer_id %}
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Stripe customer</span>
|
||||
<div class="font-monospace" style="font-size:.75rem;"
|
||||
title="{{ tenant.stripe_customer_id }}">
|
||||
{{ tenant.stripe_customer_id[:20] }}…
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1"><span class="text-muted">Stripe customer</span>
|
||||
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_customer_id }}">
|
||||
{{ tenant.stripe_customer_id }}</div></div>
|
||||
{% endif %}
|
||||
{% if tenant.stripe_subscription_id %}
|
||||
<div class="mb-1">
|
||||
<span class="text-muted">Stripe subscription</span>
|
||||
<div class="font-monospace" style="font-size:.75rem;"
|
||||
title="{{ tenant.stripe_subscription_id }}">
|
||||
{{ tenant.stripe_subscription_id[:20] }}…
|
||||
<div class="mb-2"><span class="text-muted">Stripe subscription</span>
|
||||
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_subscription_id }}">
|
||||
{{ tenant.stripe_subscription_id }}</div></div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
{# Set trial #}
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
|
||||
class="mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="set_trial">
|
||||
<label class="form-label mb-1" style="font-size:.8rem;">Extend / Set Trial</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" name="trial_days" value="14" min="1" max="365"
|
||||
class="form-control" style="max-width:70px;">
|
||||
<span class="input-group-text">days</span>
|
||||
<button class="btn btn-outline-info btn-sm">Set Trial</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not tenant.subscription_status %}
|
||||
<span class="text-muted">No billing configured.</span>
|
||||
</form>
|
||||
|
||||
{# Override status #}
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
|
||||
class="mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="set_status">
|
||||
<label class="form-label mb-1" style="font-size:.8rem;">Override Status</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<select name="subscription_status" class="form-select">
|
||||
{% for st in ('trial','active','past_due','cancelled') %}
|
||||
<option value="{{ st }}" {{ 'selected' if tenant.subscription_status == st }}>
|
||||
{{ st }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-outline-warning btn-sm">Set</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Apply coupon — only if Stripe customer exists #}
|
||||
{% if tenant.stripe_customer_id %}
|
||||
<form method="POST"
|
||||
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
|
||||
onsubmit="return confirm('Apply coupon to Stripe customer?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="apply_coupon">
|
||||
<label class="form-label mb-1" style="font-size:.8rem;">Apply Stripe Coupon</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="coupon_id" class="form-control"
|
||||
placeholder="COUPON_ID" required>
|
||||
<button class="btn btn-outline-success btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -376,6 +376,72 @@ def provision_tenant():
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=info['tenant_id']))
|
||||
|
||||
|
||||
# ── billing controls (superadmin override) ────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:tenant_id>/billing', methods=['POST'])
|
||||
@superadmin_required
|
||||
def update_billing(tenant_id):
|
||||
"""Superadmin billing controls: extend trial, override status, apply coupon."""
|
||||
action = (request.form.get('action') or '').strip()
|
||||
|
||||
with control_session() as s:
|
||||
t = s.get(Tenant, tenant_id)
|
||||
if t is None:
|
||||
flash('Tenant not found.', 'danger')
|
||||
return redirect(url_for('tenants.list_tenants'))
|
||||
|
||||
if action == 'set_trial':
|
||||
from datetime import timedelta
|
||||
try:
|
||||
days = int(request.form.get('trial_days', 14))
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid trial days.', 'danger')
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||
new_ends = now_eastern() + timedelta(days=days)
|
||||
t.subscription_status = 'trial'
|
||||
t.trial_ends_at = new_ends
|
||||
_audit('BILLING_TRIAL', tenant_id=tenant_id,
|
||||
details=f'days={days} ends_at={new_ends.date()}')
|
||||
flash(f'Trial set to {days} days (expires {new_ends.strftime("%b %d, %Y")}).', 'success')
|
||||
|
||||
elif action == 'set_status':
|
||||
status = (request.form.get('subscription_status') or '').strip()
|
||||
allowed = ('trial', 'active', 'past_due', 'cancelled')
|
||||
if status not in allowed:
|
||||
flash(f'Invalid status. Choose from: {", ".join(allowed)}.', 'danger')
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||
old = t.subscription_status
|
||||
t.subscription_status = status
|
||||
_audit('BILLING_STATUS', tenant_id=tenant_id,
|
||||
details=f'status {old} → {status}')
|
||||
flash(f'Subscription status updated to "{status}".', 'success')
|
||||
|
||||
elif action == 'apply_coupon':
|
||||
coupon_id = (request.form.get('coupon_id') or '').strip()
|
||||
if not coupon_id:
|
||||
flash('Coupon ID is required.', 'danger')
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||
if not t.stripe_customer_id:
|
||||
flash('Tenant has no Stripe customer — cannot apply coupon.', 'danger')
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||
try:
|
||||
import os
|
||||
import stripe as _stripe
|
||||
_stripe.api_key = os.environ.get('STRIPE_SECRET_KEY', '')
|
||||
_stripe.Customer.modify(t.stripe_customer_id, coupon=coupon_id)
|
||||
_audit('BILLING_COUPON', tenant_id=tenant_id,
|
||||
details=f'coupon={coupon_id} customer={t.stripe_customer_id}')
|
||||
flash(f'Coupon "{coupon_id}" applied to Stripe customer.', 'success')
|
||||
except Exception as exc:
|
||||
logger.error('PANEL | apply_coupon | tenant=%s err=%s', tenant_id, exc)
|
||||
flash(f'Stripe error: {exc}', 'danger')
|
||||
|
||||
else:
|
||||
flash('Unknown billing action.', 'danger')
|
||||
|
||||
return redirect(url_for('tenants.tenant_detail', tenant_id=tenant_id))
|
||||
|
||||
|
||||
# ── run migration ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:tenant_id>/migrate', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user