05/06 Phase 2: updated and added new files

This commit is contained in:
2026-05-06 17:40:21 -04:00
parent d924e41d9e
commit 8da0c11290
28 changed files with 2113 additions and 35 deletions
+13 -8
View File
@@ -668,14 +668,19 @@ WantedBy=multi-user.target
- [x] `README.md` — full deployment runbook (fresh install, migrations, seeding, service management, backup) - [x] `README.md` — full deployment runbook (fresh install, migrations, seeding, service management, backup)
- [ ] Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow) - [ ] Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow)
### Phase 2 — Admin Portal ### Phase 2 — Admin Portal ✅ COMPLETE
- [ ] System user management (CRUD, force password reset) - [x] System user management (CRUD, force password reset, activate/deactivate)
- [ ] Tenant management (create, edit, suspend, cancel, assign plan) - [x] Tenant management (create with owner account + primary location, edit, suspend/cancel/activate, assign plan)
- [ ] Plan management (create/edit, feature flags, limits) - [x] Plan management (create/edit, feature flags via checkboxes, max staff/locations, activate/deactivate)
- [ ] Billing history (manual invoice entry, per-tenant view) - [x] Billing history (manual invoice entry per tenant, global list view, per-tenant view)
- [ ] Tenant settings override (set/lift with before/after audit trail) - [x] Tenant settings override (set with note, lift individually, history view, before/after audit trail)
- [ ] Audit log viewer (filter by actor, action, date; export) - [x] Audit log viewer (filter by actor/action/target/date, paginated, CSV export)
- [ ] Platform analytics dashboard - [x] Platform analytics dashboard (MRR, active/trial/churn counts, revenue 30d, trials expiring soon, plan distribution)
- [x] `app/admin/utils.py``@superadmin_required`, `log_admin_action`, `model_to_dict` shared helpers
- [x] All Phase 2 blueprints registered in `create_admin_app()` factory
- [x] Admin base template nav links wired to all Phase 2 routes
- [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules
- [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1`
### Phase 3 — Multi-Location & Tenant Core Modules ### Phase 3 — Multi-Location & Tenant Core Modules
- [ ] Location management (CRUD, primary flag, per-location settings) - [ ] Location management (CRUD, primary flag, per-location settings)
+10 -3
View File
@@ -71,10 +71,17 @@ def create_admin_app(config_override=None):
# ── Blueprints ──────────────────────────────────────────── # ── Blueprints ────────────────────────────────────────────
from app.admin.auth.routes import admin_auth_bp from app.admin.auth.routes import admin_auth_bp
flask_app.register_blueprint(admin_auth_bp) from app.admin.system_users.routes import system_users_bp
from app.admin.tenants.routes import tenants_bp
from app.admin.plans.routes import plans_bp
from app.admin.billing.routes import billing_bp
from app.admin.settings_override.routes import settings_override_bp
from app.admin.audit_log.routes import audit_log_bp
from app.admin.analytics.routes import analytics_bp
# Placeholder blueprints registered in later phases: for bp in [admin_auth_bp, system_users_bp, tenants_bp, plans_bp,
# system_users, tenants, plans, billing, settings_override, audit_log, analytics billing_bp, settings_override_bp, audit_log_bp, analytics_bp]:
flask_app.register_blueprint(bp)
# ── Import all models for Migrate ───────────────────────── # ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401 import app.models # noqa: F401
+109 -2
View File
@@ -1,7 +1,114 @@
""" """
app/admin/analytics/routes.py app/admin/analytics/routes.py
Phase 2 implementation. Platform analytics dashboard: active tenants, MRR, trial conversions,
churn rate, tenant health signals.
""" """
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta
from flask import Blueprint, render_template
from flask_login import login_required
from sqlalchemy import func
from app.extensions import db
from app.models.platform import Tenant, Plan, TenantBillingHistory
from app.admin.utils import superadmin_required
logger = logging.getLogger(__name__)
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics") analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
@analytics_bp.route("/")
@login_required
@superadmin_required
def index():
now = datetime.now(timezone.utc)
thirty_days_ago = now - timedelta(days=30)
# ── Tenant counts by status ───────────────────────────────
counts = (
db.session.query(Tenant.status, func.count(Tenant.id))
.group_by(Tenant.status)
.all()
)
status_counts = {s: c for s, c in counts}
total_tenants = sum(status_counts.values())
active_count = status_counts.get("active", 0)
trial_count = status_counts.get("trial", 0)
suspended_count = status_counts.get("suspended", 0)
cancelled_count = status_counts.get("cancelled", 0)
# ── MRR — sum of monthly prices for active non-demo tenants ──
mrr_rows = (
db.session.query(Plan.price_monthly, func.count(Tenant.id))
.join(Tenant, Tenant.plan_id == Plan.id)
.filter(Tenant.status == "active", Tenant.is_demo == False) # noqa: E712
.group_by(Plan.price_monthly)
.all()
)
mrr = sum(price * count for price, count in mrr_rows)
# ── Trial conversions last 30 days ───────────────────────
converted = Tenant.query.filter(
Tenant.status == "active",
Tenant.trial_ends_at >= thirty_days_ago,
Tenant.trial_ends_at <= now,
).count()
# ── Churn last 30 days ────────────────────────────────────
churned = Tenant.query.filter(
Tenant.status.in_(["suspended", "cancelled"]),
Tenant.updated_at >= thirty_days_ago,
).count()
# ── New tenants last 30 days ─────────────────────────────
new_tenants = Tenant.query.filter(
Tenant.created_at >= thirty_days_ago
).count()
# ── Tenant health table (trials expiring soon) ────────────
expiring_soon = Tenant.query.filter(
Tenant.status == "trial",
Tenant.trial_ends_at <= now + timedelta(days=3),
Tenant.trial_ends_at >= now,
).order_by(Tenant.trial_ends_at.asc()).all()
# ── Revenue last 30 days (from billing history) ───────────
revenue_30d_row = db.session.query(
func.sum(TenantBillingHistory.amount)
).filter(
TenantBillingHistory.created_at >= thirty_days_ago
).scalar()
revenue_30d = float(revenue_30d_row or 0)
# ── Tenants by plan ───────────────────────────────────────
plan_counts = (
db.session.query(Plan.name, func.count(Tenant.id))
.join(Tenant, Tenant.plan_id == Plan.id)
.filter(Tenant.status.in_(["active", "trial"]))
.group_by(Plan.name)
.all()
)
stats = {
"total_tenants": total_tenants,
"active_count": active_count,
"trial_count": trial_count,
"suspended_count": suspended_count,
"cancelled_count": cancelled_count,
"mrr": mrr,
"converted_30d": converted,
"churned_30d": churned,
"new_tenants_30d": new_tenants,
"revenue_30d": revenue_30d,
"plan_counts": plan_counts,
}
return render_template(
"admin/analytics/index.html",
stats=stats,
expiring_soon=expiring_soon,
now=now,
)
+112 -2
View File
@@ -1,7 +1,117 @@
""" """
app/admin/audit_log/routes.py app/admin/audit_log/routes.py
Phase 2 implementation. Audit log viewer: filterable by actor, action prefix, date range.
CSV export. Append-only no modifications permitted.
""" """
from flask import Blueprint
import csv
import io
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, request, Response
from flask_login import login_required
from app.models.platform import AuditLog, SystemUser
from app.admin.utils import superadmin_required
logger = logging.getLogger(__name__)
audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log") audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log")
_PAGE_SIZE = 50
@audit_log_bp.route("/")
@login_required
@superadmin_required
def index():
actor_id = request.args.get("actor_id", type=int)
action_prefix = request.args.get("action", "").strip()
target_type = request.args.get("target_type", "").strip()
date_from_raw = request.args.get("date_from", "").strip()
date_to_raw = request.args.get("date_to", "").strip()
page = request.args.get("page", 1, type=int)
q = AuditLog.query.order_by(AuditLog.created_at.desc())
if actor_id:
q = q.filter_by(actor_id=actor_id)
if action_prefix:
q = q.filter(AuditLog.action.startswith(action_prefix))
if target_type:
q = q.filter_by(target_type=target_type)
if date_from_raw:
try:
q = q.filter(AuditLog.created_at >= datetime.fromisoformat(date_from_raw))
except ValueError:
pass
if date_to_raw:
try:
q = q.filter(AuditLog.created_at <= datetime.fromisoformat(date_to_raw))
except ValueError:
pass
pagination = q.paginate(page=page, per_page=_PAGE_SIZE, error_out=False)
system_users = SystemUser.query.order_by(SystemUser.name).all()
return render_template(
"admin/audit_log/index.html",
pagination=pagination,
entries=pagination.items,
system_users=system_users,
filters={
"actor_id": actor_id, "action": action_prefix,
"target_type": target_type, "date_from": date_from_raw, "date_to": date_to_raw,
},
)
@audit_log_bp.route("/export.csv")
@login_required
@superadmin_required
def export_csv():
"""Export filtered audit log as CSV (max 10,000 rows)."""
actor_id = request.args.get("actor_id", type=int)
action_prefix = request.args.get("action", "").strip()
target_type = request.args.get("target_type", "").strip()
date_from_raw = request.args.get("date_from", "").strip()
date_to_raw = request.args.get("date_to", "").strip()
q = AuditLog.query.order_by(AuditLog.created_at.desc())
if actor_id:
q = q.filter_by(actor_id=actor_id)
if action_prefix:
q = q.filter(AuditLog.action.startswith(action_prefix))
if target_type:
q = q.filter_by(target_type=target_type)
if date_from_raw:
try:
q = q.filter(AuditLog.created_at >= datetime.fromisoformat(date_from_raw))
except ValueError:
pass
if date_to_raw:
try:
q = q.filter(AuditLog.created_at <= datetime.fromisoformat(date_to_raw))
except ValueError:
pass
entries = q.limit(10000).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "created_at", "actor_id", "actor_type", "action",
"target_type", "target_id", "ip_address",
])
for e in entries:
writer.writerow([
e.id, e.created_at, e.actor_id, e.actor_type, e.action,
e.target_type, e.target_id, e.ip_address,
])
logger.info("Audit log exported: %d rows", len(entries))
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=audit_log.csv"},
)
+1 -3
View File
@@ -75,9 +75,7 @@ def login():
@admin_auth_bp.route("/dashboard") @admin_auth_bp.route("/dashboard")
@login_required @login_required
def dashboard_redirect(): def dashboard_redirect():
# Phase 2: redirect to analytics/tenants dashboard return redirect(url_for("analytics.index"))
flash("Welcome to the Admin Portal. Feature modules coming in Phase 2.", "info")
return render_template("admin/auth/login.html", error=None)
@admin_auth_bp.route("/logout") @admin_auth_bp.route("/logout")
+87 -2
View File
@@ -1,7 +1,92 @@
""" """
app/admin/billing/routes.py app/admin/billing/routes.py
Phase 2 implementation. Billing history: manual invoice entry per tenant, list view.
All entries logged. Superadmin only.
""" """
from flask import Blueprint
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models.platform import Tenant, TenantBillingHistory
from app.admin.utils import superadmin_required, log_admin_action
logger = logging.getLogger(__name__)
billing_bp = Blueprint("billing", __name__, url_prefix="/billing") billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
@billing_bp.route("/")
@login_required
@superadmin_required
def index():
entries = (
TenantBillingHistory.query
.order_by(TenantBillingHistory.created_at.desc())
.limit(100)
.all()
)
return render_template("admin/billing/index.html", entries=entries)
@billing_bp.route("/tenant/<int:tenant_id>")
@login_required
@superadmin_required
def tenant_billing(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
entries = (
TenantBillingHistory.query
.filter_by(tenant_id=tenant_id)
.order_by(TenantBillingHistory.created_at.desc())
.all()
)
return render_template("admin/billing/tenant.html", tenant=tenant, entries=entries)
@billing_bp.route("/tenant/<int:tenant_id>/add", methods=["GET", "POST"])
@login_required
@superadmin_required
def add_entry(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
if request.method == "POST":
try:
amount = float(request.form.get("amount", 0))
except ValueError:
return render_template("admin/billing/form.html", tenant=tenant,
error="Invalid amount.")
description = request.form.get("description", "").strip()
invoice_ref = request.form.get("invoice_ref", "").strip() or None
paid_at_raw = request.form.get("paid_at", "").strip()
paid_at = None
if paid_at_raw:
try:
paid_at = datetime.fromisoformat(paid_at_raw)
except ValueError:
pass
if not description:
return render_template("admin/billing/form.html", tenant=tenant,
error="Description is required.")
entry = TenantBillingHistory(
tenant_id=tenant_id, amount=amount, description=description,
invoice_ref=invoice_ref, paid_at=paid_at,
recorded_by=current_user.id,
)
db.session.add(entry)
db.session.flush()
log_admin_action(
"billing.add_entry", "tenant", tenant_id,
after={"amount": amount, "description": description, "invoice_ref": invoice_ref},
)
db.session.commit()
logger.info("Billing entry added: tenant_id=%s amount=%s by=%s",
tenant_id, amount, current_user.id)
flash(f"Billing entry added for \'{tenant.name}\'.", "success")
return redirect(url_for("billing.tenant_billing", tenant_id=tenant_id))
return render_template("admin/billing/form.html", tenant=tenant)
+121 -2
View File
@@ -1,7 +1,126 @@
""" """
app/admin/plans/routes.py app/admin/plans/routes.py
Phase 2 implementation. Subscription plan management: create, edit, toggle active.
Feature flags stored as JSON. All changes logged.
""" """
from flask import Blueprint
import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from app.extensions import db
from app.models.platform import Plan
from app.admin.utils import superadmin_required, log_admin_action, model_to_dict
logger = logging.getLogger(__name__)
plans_bp = Blueprint("plans", __name__, url_prefix="/plans") plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
_AUDIT_FIELDS = ["name", "price_monthly", "max_staff", "max_locations", "features_json", "is_active"]
# All known feature flags — used to build the form checkboxes
ALL_FEATURES = [
"pos", "appointments", "customers", "services", "promotions",
"appointment_reminders", "customer_reviews", "reconciliation",
"basic_reports", "inventory", "commission", "full_reports",
"multi_location", "online_booking", "waitlist", "marketing",
]
@plans_bp.route("/")
@login_required
@superadmin_required
def index():
all_plans = Plan.query.order_by(Plan.price_monthly.asc()).all()
return render_template("admin/plans/index.html", plans=all_plans)
@plans_bp.route("/new", methods=["GET", "POST"])
@login_required
@superadmin_required
def create():
if request.method == "POST":
plan, error = _plan_from_form(None)
if error:
return render_template("admin/plans/form.html", mode="create",
all_features=ALL_FEATURES, error=error)
db.session.add(plan)
db.session.flush()
log_admin_action("plan.create", "plan", plan.id,
after=model_to_dict(plan, _AUDIT_FIELDS))
db.session.commit()
logger.info("Plan created: id=%s name=%s", plan.id, plan.name)
flash(f"Plan \'{plan.name}\' created.", "success")
return redirect(url_for("plans.index"))
return render_template("admin/plans/form.html", mode="create", all_features=ALL_FEATURES)
@plans_bp.route("/<int:plan_id>/edit", methods=["GET", "POST"])
@login_required
@superadmin_required
def edit(plan_id):
plan = Plan.query.get_or_404(plan_id)
before = model_to_dict(plan, _AUDIT_FIELDS)
if request.method == "POST":
_, error = _plan_from_form(plan)
if error:
return render_template("admin/plans/form.html", mode="edit",
plan=plan, all_features=ALL_FEATURES, error=error)
log_admin_action("plan.edit", "plan", plan.id,
before=before, after=model_to_dict(plan, _AUDIT_FIELDS))
db.session.commit()
logger.info("Plan edited: id=%s name=%s", plan.id, plan.name)
flash(f"Plan \'{plan.name}\' updated.", "success")
return redirect(url_for("plans.index"))
return render_template("admin/plans/form.html", mode="edit",
plan=plan, all_features=ALL_FEATURES)
@plans_bp.route("/<int:plan_id>/toggle-active", methods=["POST"])
@login_required
@superadmin_required
def toggle_active(plan_id):
plan = Plan.query.get_or_404(plan_id)
before = model_to_dict(plan, _AUDIT_FIELDS)
plan.is_active = not plan.is_active
action = "plan.activate" if plan.is_active else "plan.deactivate"
log_admin_action(action, "plan", plan.id, before=before,
after=model_to_dict(plan, _AUDIT_FIELDS))
db.session.commit()
status = "activated" if plan.is_active else "deactivated"
flash(f"Plan \'{plan.name}\' {status}.", "success")
return redirect(url_for("plans.index"))
def _plan_from_form(plan):
"""Parse form data into a Plan object. Returns (plan, error_string|None)."""
name = request.form.get("name", "").strip()
if not name:
return plan, "Plan name is required."
try:
price = float(request.form.get("price_monthly", 0))
except ValueError:
return plan, "Invalid price."
max_staff_raw = request.form.get("max_staff", "").strip()
max_loc_raw = request.form.get("max_locations", "").strip()
max_staff = int(max_staff_raw) if max_staff_raw.isdigit() else None
max_locations = int(max_loc_raw) if max_loc_raw.isdigit() else None
features = {flag: (request.form.get(f"feature_{flag}") == "1") for flag in ALL_FEATURES}
if plan is None:
plan = Plan(name=name)
else:
plan.name = name
plan.price_monthly = price
plan.max_staff = max_staff
plan.max_locations = max_locations
plan.features_json = features
plan.is_active = request.form.get("is_active") == "1"
return plan, None
+117 -2
View File
@@ -1,7 +1,122 @@
""" """
app/admin/settings_override/routes.py app/admin/settings_override/routes.py
Phase 2 implementation. Superadmin tenant settings overrides: set, list active, lift.
Overrides take precedence over tenant_settings at the application layer.
All actions logged with before/after values.
""" """
from flask import Blueprint
import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app.extensions import db
from app.models.platform import Tenant, TenantSettingOverride
from app.admin.utils import superadmin_required, log_admin_action
from app.security import validate_setting_key
logger = logging.getLogger(__name__)
settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override") settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override")
@settings_override_bp.route("/tenant/<int:tenant_id>")
@login_required
@superadmin_required
def tenant_overrides(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
active = (
TenantSettingOverride.query
.filter_by(tenant_id=tenant_id)
.filter(TenantSettingOverride.lifted_at.is_(None))
.order_by(TenantSettingOverride.overridden_at.desc())
.all()
)
history = (
TenantSettingOverride.query
.filter_by(tenant_id=tenant_id)
.filter(TenantSettingOverride.lifted_at.isnot(None))
.order_by(TenantSettingOverride.lifted_at.desc())
.limit(50)
.all()
)
return render_template("admin/settings_override/list.html",
tenant=tenant, active=active, history=history)
@settings_override_bp.route("/tenant/<int:tenant_id>/set", methods=["GET", "POST"])
@login_required
@superadmin_required
def set_override(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
if request.method == "POST":
setting_key = request.form.get("setting_key", "").strip()
setting_value = request.form.get("setting_value", "").strip()
note = request.form.get("note", "").strip() or None
if not validate_setting_key(setting_key):
return render_template("admin/settings_override/form.html", tenant=tenant,
error="Invalid setting key (alphanumeric, underscores, dots only).")
if not setting_value:
return render_template("admin/settings_override/form.html", tenant=tenant,
error="Setting value is required.")
# Lift any existing active override for the same key
existing = (
TenantSettingOverride.query
.filter_by(tenant_id=tenant_id, setting_key=setting_key)
.filter(TenantSettingOverride.lifted_at.is_(None))
.first()
)
old_value = None
if existing:
old_value = existing.setting_value
existing.lifted_at = datetime.now(timezone.utc)
override = TenantSettingOverride(
tenant_id=tenant_id,
setting_key=setting_key,
setting_value=setting_value,
overridden_by=current_user.id,
note=note,
)
db.session.add(override)
db.session.flush()
log_admin_action(
"settings_override.set", "tenant", tenant_id,
before={"setting_key": setting_key, "setting_value": old_value},
after={"setting_key": setting_key, "setting_value": setting_value, "note": note},
)
db.session.commit()
logger.info("Settings override set: tenant=%s key=%s by=%s",
tenant_id, setting_key, current_user.id)
flash(f"Override for \'{setting_key}\' set on tenant \'{tenant.name}\'.", "success")
return redirect(url_for("settings_override.tenant_overrides", tenant_id=tenant_id))
return render_template("admin/settings_override/form.html", tenant=tenant)
@settings_override_bp.route("/<int:override_id>/lift", methods=["POST"])
@login_required
@superadmin_required
def lift_override(override_id):
override = TenantSettingOverride.query.get_or_404(override_id)
if override.lifted_at is not None:
flash("This override has already been lifted.", "warning")
return redirect(url_for("settings_override.tenant_overrides",
tenant_id=override.tenant_id))
override.lifted_at = datetime.now(timezone.utc)
log_admin_action(
"settings_override.lift", "tenant", override.tenant_id,
before={"setting_key": override.setting_key, "setting_value": override.setting_value},
after={"setting_key": override.setting_key, "lifted_at": str(override.lifted_at)},
)
db.session.commit()
logger.info("Override lifted: id=%s tenant=%s key=%s by=%s",
override_id, override.tenant_id, override.setting_key, current_user.id)
flash(f"Override for \'{override.setting_key}\' lifted.", "success")
return redirect(url_for("settings_override.tenant_overrides",
tenant_id=override.tenant_id))
+138 -2
View File
@@ -1,7 +1,143 @@
""" """
app/admin/system_users/routes.py app/admin/system_users/routes.py
Phase 2 implementation. System user management: create, edit, deactivate, force password reset.
All actions logged to AuditLog. Superadmin only.
""" """
from flask import Blueprint
import logging
import secrets
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request,
)
from flask_login import login_required, current_user
from app.extensions import db, bcrypt, mail
from app.models.platform import SystemUser
from app.admin.utils import superadmin_required, log_admin_action, model_to_dict
logger = logging.getLogger(__name__)
system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users") system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users")
_AUDIT_FIELDS = ["email", "name", "role", "is_active"]
@system_users_bp.route("/")
@login_required
@superadmin_required
def index():
users = SystemUser.query.order_by(SystemUser.created_at.desc()).all()
return render_template("admin/system_users/index.html", users=users)
@system_users_bp.route("/new", methods=["GET", "POST"])
@login_required
@superadmin_required
def create():
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
name = request.form.get("name", "").strip()
role = request.form.get("role", "superadmin").strip()
password = request.form.get("password", "")
confirm = request.form.get("confirm_password", "")
from app.forms import validate_password_strength
error = validate_password_strength(password, confirm)
if error:
return render_template("admin/system_users/form.html", error=error, mode="create")
if SystemUser.query.filter_by(email=email).first():
return render_template(
"admin/system_users/form.html",
error="A user with that email already exists.", mode="create",
)
user = SystemUser(
email=email, name=name, role=role,
password_hash=bcrypt.generate_password_hash(password).decode("utf-8"),
is_active=True,
)
db.session.add(user)
db.session.flush()
log_admin_action("system_user.create", "system_user", user.id,
after=model_to_dict(user, _AUDIT_FIELDS))
db.session.commit()
logger.info("System user created: id=%s email=%s", user.id, email)
flash(f"System user \'{email}\' created.", "success")
return redirect(url_for("system_users.index"))
return render_template("admin/system_users/form.html", mode="create")
@system_users_bp.route("/<int:user_id>/edit", methods=["GET", "POST"])
@login_required
@superadmin_required
def edit(user_id):
user = SystemUser.query.get_or_404(user_id)
before = model_to_dict(user, _AUDIT_FIELDS)
if request.method == "POST":
user.name = request.form.get("name", user.name).strip()
user.role = request.form.get("role", user.role).strip()
user.is_active = request.form.get("is_active") == "1"
log_admin_action("system_user.edit", "system_user", user.id,
before=before, after=model_to_dict(user, _AUDIT_FIELDS))
db.session.commit()
logger.info("System user edited: id=%s", user.id)
flash(f"User \'{user.email}\' updated.", "success")
return redirect(url_for("system_users.index"))
return render_template("admin/system_users/form.html", mode="edit", user=user)
@system_users_bp.route("/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@superadmin_required
def toggle_active(user_id):
user = SystemUser.query.get_or_404(user_id)
if user.id == current_user.id:
flash("You cannot deactivate your own account.", "danger")
return redirect(url_for("system_users.index"))
before = model_to_dict(user, _AUDIT_FIELDS)
user.is_active = not user.is_active
action = "system_user.activate" if user.is_active else "system_user.deactivate"
log_admin_action(action, "system_user", user.id,
before=before, after=model_to_dict(user, _AUDIT_FIELDS))
db.session.commit()
status = "activated" if user.is_active else "deactivated"
logger.info("System user %s: id=%s", status, user.id)
flash(f"User \'{user.email}\' {status}.", "success")
return redirect(url_for("system_users.index"))
@system_users_bp.route("/<int:user_id>/force-password-reset", methods=["POST"])
@login_required
@superadmin_required
def force_password_reset(user_id):
user = SystemUser.query.get_or_404(user_id)
token = secrets.token_urlsafe(32)
user.password_reset_token = bcrypt.generate_password_hash(token).decode("utf-8")
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
user.failed_login_attempts = 0
user.locked_until = None
log_admin_action("system_user.force_password_reset", "system_user", user.id)
db.session.commit()
reset_url = url_for("admin_auth.password_reset_confirm", token=token, _external=True)
try:
from flask_mail import Message
msg = Message(
subject="Admin Portal — Password Reset by Administrator",
recipients=[user.email],
body=f"A password reset has been initiated.\n\nReset link (24 hours):\n\n{reset_url}",
)
mail.send(msg)
flash(f"Password reset email sent to \'{user.email}\'.", "success")
except Exception as exc:
logger.error("Force reset email failed: %s", exc)
flash(f"Token generated but email failed. Reset URL: {reset_url}", "warning")
return redirect(url_for("system_users.index"))
+172 -2
View File
@@ -1,7 +1,177 @@
""" """
app/admin/tenants/routes.py app/admin/tenants/routes.py
Phase 2 implementation. Tenant management: create, view, edit, suspend, cancel, assign plan.
All status changes logged. Superadmin only.
""" """
from flask import Blueprint
import logging
from datetime import datetime, timezone, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required
from app.extensions import db, bcrypt
from app.models.platform import Tenant, Plan
from app.models.salon import User, Location
from app.admin.utils import superadmin_required, log_admin_action, model_to_dict
logger = logging.getLogger(__name__)
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants") tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
_AUDIT_FIELDS = ["slug", "name", "owner_email", "plan_id", "status", "is_demo",
"trial_ends_at", "subscription_expires_at"]
@tenants_bp.route("/")
@login_required
@superadmin_required
def index():
status_filter = request.args.get("status", "")
q = Tenant.query.order_by(Tenant.created_at.desc())
if status_filter:
q = q.filter_by(status=status_filter)
tenants = q.all()
return render_template("admin/tenants/index.html", tenants=tenants,
status_filter=status_filter)
@tenants_bp.route("/<int:tenant_id>")
@login_required
@superadmin_required
def detail(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
locations = Location.query.filter_by(tenant_id=tenant_id).all()
users = User.query.filter_by(tenant_id=tenant_id).all()
from app.models.platform import TenantBillingHistory, TenantSettingOverride
billing = TenantBillingHistory.query.filter_by(
tenant_id=tenant_id).order_by(TenantBillingHistory.created_at.desc()).limit(20).all()
overrides = TenantSettingOverride.query.filter_by(
tenant_id=tenant_id).filter(TenantSettingOverride.lifted_at.is_(None)).all()
return render_template(
"admin/tenants/detail.html", tenant=tenant, plans=plans,
locations=locations, users=users, billing=billing, overrides=overrides,
)
@tenants_bp.route("/new", methods=["GET", "POST"])
@login_required
@superadmin_required
def create():
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
if request.method == "POST":
slug = request.form.get("slug", "").strip().lower()
name = request.form.get("name", "").strip()
owner_email = request.form.get("owner_email", "").strip().lower()
plan_id = request.form.get("plan_id", type=int)
owner_password = request.form.get("owner_password", "")
confirm = request.form.get("confirm_password", "")
trial_days = request.form.get("trial_days", "14")
from app.security import validate_slug
if not validate_slug(slug):
return render_template("admin/tenants/form.html", mode="create",
plans=plans, error="Invalid slug (lowercase letters, digits, hyphens only).")
if Tenant.query.filter_by(slug=slug).first():
return render_template("admin/tenants/form.html", mode="create",
plans=plans, error="That slug is already taken.")
from app.forms import validate_password_strength
err = validate_password_strength(owner_password, confirm)
if err:
return render_template("admin/tenants/form.html", mode="create",
plans=plans, error=err)
if not plan_id or not Plan.query.get(plan_id):
return render_template("admin/tenants/form.html", mode="create",
plans=plans, error="Please select a valid plan.")
try:
days = int(trial_days)
except ValueError:
days = 14
tenant = Tenant(
slug=slug, name=name, owner_email=owner_email,
plan_id=plan_id, status="trial",
trial_ends_at=datetime.now(timezone.utc) + timedelta(days=days),
)
db.session.add(tenant)
db.session.flush()
# Primary location
location = Location(
tenant_id=tenant.id, name=name,
is_primary=True, is_active=True,
)
db.session.add(location)
# Owner user
owner = User(
tenant_id=tenant.id, email=owner_email, role="tenant_admin",
password_hash=bcrypt.generate_password_hash(owner_password).decode("utf-8"),
is_active=True,
)
db.session.add(owner)
log_admin_action("tenant.create", "tenant", tenant.id,
after=model_to_dict(tenant, _AUDIT_FIELDS))
db.session.commit()
logger.info("Tenant created: id=%s slug=%s", tenant.id, slug)
flash(f"Tenant \'{name}\' created (slug: {slug}).", "success")
return redirect(url_for("tenants.detail", tenant_id=tenant.id))
return render_template("admin/tenants/form.html", mode="create", plans=plans)
@tenants_bp.route("/<int:tenant_id>/edit", methods=["GET", "POST"])
@login_required
@superadmin_required
def edit(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
before = model_to_dict(tenant, _AUDIT_FIELDS)
if request.method == "POST":
tenant.name = request.form.get("name", tenant.name).strip()
tenant.owner_email = request.form.get("owner_email", tenant.owner_email).strip().lower()
new_plan_id = request.form.get("plan_id", type=int)
if new_plan_id and Plan.query.get(new_plan_id):
tenant.plan_id = new_plan_id
sub_expires_raw = request.form.get("subscription_expires_at", "").strip()
if sub_expires_raw:
try:
tenant.subscription_expires_at = datetime.fromisoformat(sub_expires_raw)
except ValueError:
pass
tenant.is_demo = request.form.get("is_demo") == "1"
log_admin_action("tenant.edit", "tenant", tenant.id,
before=before, after=model_to_dict(tenant, _AUDIT_FIELDS))
db.session.commit()
logger.info("Tenant edited: id=%s slug=%s", tenant.id, tenant.slug)
flash(f"Tenant \'{tenant.name}\' updated.", "success")
return redirect(url_for("tenants.detail", tenant_id=tenant.id))
return render_template("admin/tenants/form.html", mode="edit",
tenant=tenant, plans=plans)
@tenants_bp.route("/<int:tenant_id>/set-status", methods=["POST"])
@login_required
@superadmin_required
def set_status(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id)
new_status = request.form.get("status", "").strip()
allowed = {"active", "trial", "suspended", "cancelled"}
if new_status not in allowed:
flash("Invalid status.", "danger")
return redirect(url_for("tenants.detail", tenant_id=tenant_id))
before = model_to_dict(tenant, _AUDIT_FIELDS)
tenant.status = new_status
log_admin_action(f"tenant.set_status.{new_status}", "tenant", tenant.id,
before=before, after=model_to_dict(tenant, _AUDIT_FIELDS))
db.session.commit()
logger.info("Tenant status changed: id=%s slug=%s status=%s", tenant.id, tenant.slug, new_status)
flash(f"Tenant \'{tenant.name}\' status set to \'{new_status}\'.", "success")
return redirect(url_for("tenants.detail", tenant_id=tenant_id))
+55
View File
@@ -0,0 +1,55 @@
"""
app/admin/utils.py
Shared helpers used across all admin portal blueprints.
"""
import logging
from functools import wraps
from flask import abort, request
from flask_login import current_user
logger = logging.getLogger(__name__)
def superadmin_required(f):
"""
Decorator: ensures the current user is an authenticated superadmin.
Aborts 401 if unauthenticated, 403 if wrong role.
Use on every admin blueprint route.
"""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role != "superadmin":
logger.warning(
"superadmin_required: denied user id=%s role=%s endpoint=%s",
current_user.id, current_user.role, request.endpoint,
)
abort(403)
return f(*args, **kwargs)
return decorated
def log_admin_action(action, target_type=None, target_id=None, before=None, after=None):
"""
Convenience wrapper creates an AuditLog entry and adds it to the
current db session. Caller must commit.
"""
from app.models.platform import AuditLog
AuditLog.log(
actor_id=current_user.id,
actor_type="system_user",
action=action,
target_type=target_type,
target_id=target_id,
before=before,
after=after,
ip_address=request.remote_addr,
)
def model_to_dict(obj, fields):
"""Return a plain dict of the named fields from an ORM object (for audit before/after)."""
return {f: getattr(obj, f, None) for f in fields}
+6
View File
@@ -98,3 +98,9 @@ def sanitise_string(value: str, max_length: int = None) -> str:
def validate_slug(slug: str) -> bool: def validate_slug(slug: str) -> bool:
"""Return True if the slug matches the allowed pattern.""" """Return True if the slug matches the allowed pattern."""
return bool(_SLUG_RE.match(slug)) return bool(_SLUG_RE.match(slug))
def validate_setting_key(key: str) -> bool:
"""Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
import re
return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100)
+84
View File
@@ -0,0 +1,84 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Platform Analytics{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4"><i class="bi bi-bar-chart me-2"></i>Platform Analytics</h4>
<div class="row g-3 mb-4">
{% set kpis = [
('Total Tenants', stats.total_tenants, 'building', 'primary'),
('Active', stats.active_count, 'check-circle', 'success'),
('Trial', stats.trial_count, 'hourglass-split', 'info'),
('Suspended/Cancelled', stats.suspended_count + stats.cancelled_count, 'x-circle', 'danger'),
('MRR', '$' ~ "%.2f"|format(stats.mrr), 'currency-dollar', 'success'),
('Revenue (30d)', '$' ~ "%.2f"|format(stats.revenue_30d), 'graph-up', 'primary'),
('New Tenants (30d)', stats.new_tenants_30d, 'person-plus', 'secondary'),
('Conversions (30d)', stats.converted_30d, 'arrow-up-circle', 'success'),
('Churn (30d)', stats.churned_30d, 'arrow-down-circle', 'danger'),
] %}
{% for label, value, icon, color in kpis %}
<div class="col-md-4 col-lg-3">
<div class="card shadow-sm border-0">
<div class="card-body d-flex align-items-center gap-3">
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-3">
<i class="bi bi-{{ icon }} fs-4 text-{{ color }}"></i>
</div>
<div>
<div class="fs-5 fw-bold">{{ value }}</div>
<div class="text-muted small">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-3">
<!-- Tenants by plan -->
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold">Active Tenants by Plan</div>
<div class="card-body">
{% for plan_name, count in stats.plan_counts %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ plan_name }}</span>
<span class="badge bg-primary rounded-pill">{{ count }}</span>
</div>
{% else %}
<p class="text-muted small">No data yet.</p>
{% endfor %}
</div>
</div>
</div>
<!-- Trials expiring soon -->
<div class="col-md-7">
<div class="card shadow-sm h-100">
<div class="card-header fw-semibold text-warning-emphasis">
<i class="bi bi-hourglass me-1"></i>Trials Expiring in 3 Days
</div>
{% if expiring_soon %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Tenant</th><th>Plan</th><th>Trial Ends</th><th></th></tr></thead>
<tbody>
{% for t in expiring_soon %}
<tr>
<td><a href="{{ url_for('tenants.detail', tenant_id=t.id) }}">{{ t.name }}</a></td>
<td>{{ t.plan.name if t.plan else '—' }}</td>
<td class="text-warning small fw-semibold">{{ t.trial_ends_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<a href="{{ url_for('tenants.set_status', tenant_id=t.id) }}"
class="btn btn-success btn-sm" style="display:inline-block;">Convert</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No trials expiring in the next 3 days.</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Audit Log{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h4>
<a href="{{ url_for('audit_log.export_csv', **filters) }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-download me-1"></i>Export CSV
</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form method="GET" class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small mb-1">Actor</label>
<select class="form-select form-select-sm" name="actor_id">
<option value="">All</option>
{% for u in system_users %}
<option value="{{ u.id }}" {{ 'selected' if filters.actor_id == u.id }}>{{ u.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Action prefix</label>
<input type="text" class="form-control form-control-sm font-monospace" name="action"
value="{{ filters.action }}" placeholder="e.g. tenant.">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Target type</label>
<input type="text" class="form-control form-control-sm font-monospace" name="target_type"
value="{{ filters.target_type }}" placeholder="tenant, plan…">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">From</label>
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filters.date_from }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">To</label>
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filters.date_to }}">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-dark btn-sm w-100">Filter</button>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 font-monospace" style="font-size:0.82rem;">
<thead class="table-dark">
<tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>IP</th></tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="text-nowrap">{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ e.actor_type }}:{{ e.actor_id }}</td>
<td>{{ e.action }}</td>
<td>{{ (e.target_type ~ ':' ~ e.target_id) if e.target_type else '—' }}</td>
<td class="text-muted">{{ e.ip_address or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No entries match your filter.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if pagination.pages > 1 %}
<nav class="mt-3">
<ul class="pagination pagination-sm">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.prev_num, **filters) }}">Previous</a>
</li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ pagination.page }} of {{ pagination.pages }}</span></li>
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.next_num, **filters) }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Add Billing Entry{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Add Billing Entry — {{ tenant.name }}</h5></div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Amount ($)</label>
<input type="number" step="0.01" class="form-control" name="amount" min="0" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" required>
</div>
<div class="mb-3">
<label class="form-label">Invoice Reference <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" name="invoice_ref">
</div>
<div class="mb-3">
<label class="form-label">Paid At <small class="text-muted">(optional)</small></label>
<input type="datetime-local" class="form-control" name="paid_at">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-dark">Save</button>
<a href="{{ url_for('billing.tenant_billing', tenant_id=tenant.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Billing History{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4"><i class="bi bi-receipt me-2"></i>Billing History</h4>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr><th>Date</th><th>Tenant</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
</thead>
<tbody>
{% for b in entries %}
<tr>
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('tenants.detail', tenant_id=b.tenant_id) }}" class="text-decoration-none">
{{ b.tenant.name if b.tenant else b.tenant_id }}
</a>
</td>
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
<td>{{ b.description }}</td>
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No billing entries yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Billing — {{ tenant.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">Billing — {{ tenant.name }}</h4>
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-dark btn-sm">
<i class="bi bi-plus-lg me-1"></i>Add Entry
</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
</thead>
<tbody>
{% for b in entries %}
<tr>
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
<td>{{ b.description }}</td>
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-4">No entries yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
</a>
{% endblock %}
+6 -6
View File
@@ -32,37 +32,37 @@
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('tenants.index') }}">
<i class="bi bi-building me-2"></i>Tenants <i class="bi bi-building me-2"></i>Tenants
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('system_users.index') }}">
<i class="bi bi-people me-2"></i>System Users <i class="bi bi-people me-2"></i>System Users
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('plans.index') }}">
<i class="bi bi-card-list me-2"></i>Plans <i class="bi bi-card-list me-2"></i>Plans
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('billing.index') }}">
<i class="bi bi-receipt me-2"></i>Billing <i class="bi bi-receipt me-2"></i>Billing
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('audit_log.index') }}">
<i class="bi bi-journal-text me-2"></i>Audit Log <i class="bi bi-journal-text me-2"></i>Audit Log
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}"
href="#"> href="{{ url_for('analytics.index') }}">
<i class="bi bi-bar-chart me-2"></i>Analytics <i class="bi bi-bar-chart me-2"></i>Analytics
</a> </a>
</li> </li>
+65
View File
@@ -0,0 +1,65 @@
{% extends "admin/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Plan{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} Plan</h5></div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Plan Name</label>
<input type="text" class="form-control" name="name"
value="{{ plan.name if plan else '' }}" required>
</div>
<div class="col-md-6">
<label class="form-label">Monthly Price ($)</label>
<input type="number" step="0.01" class="form-control" name="price_monthly"
value="{{ plan.price_monthly if plan else '' }}" required>
</div>
<div class="col-md-6">
<label class="form-label">Max Staff <small class="text-muted">(blank = unlimited)</small></label>
<input type="number" class="form-control" name="max_staff"
value="{{ plan.max_staff if plan and plan.max_staff else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Max Locations <small class="text-muted">(blank = unlimited)</small></label>
<input type="number" class="form-control" name="max_locations"
value="{{ plan.max_locations if plan and plan.max_locations else '' }}">
</div>
<div class="col-12">
<label class="form-label fw-semibold">Feature Flags</label>
<div class="row g-2">
{% for flag in all_features %}
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="feature_{{ flag }}" value="1"
id="f_{{ flag }}"
{{ 'checked' if plan and (plan.features_json or {}).get(flag) }}>
<label class="form-check-label small" for="f_{{ flag }}">{{ flag }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if not plan or plan.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-dark">Save</button>
<a href="{{ url_for('plans.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Plans{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-card-list me-2"></i>Subscription Plans</h4>
<a href="{{ url_for('plans.create') }}" class="btn btn-dark btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Plan
</a>
</div>
<div class="row g-3">
{% for plan in plans %}
<div class="col-md-4">
<div class="card shadow-sm h-100 {{ '' if plan.is_active else 'opacity-50' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ plan.name }}</strong>
<span class="badge {{ 'bg-success' if plan.is_active else 'bg-secondary' }}">
{{ 'Active' if plan.is_active else 'Inactive' }}
</span>
</div>
<div class="card-body">
<h3 class="fw-bold">${{ "%.2f"|format(plan.price_monthly) }}<small class="fs-6 text-muted">/mo</small></h3>
<p class="text-muted small mb-2">
Staff: {{ plan.max_staff or 'Unlimited' }} &bull;
Locations: {{ plan.max_locations or 'Unlimited' }}
</p>
<div class="mb-3">
{% for flag, enabled in (plan.features_json or {}).items() %}
<span class="badge {{ 'bg-primary' if enabled else 'bg-light text-muted' }} me-1 mb-1" style="font-size:0.7rem;">
{{ flag }}
</span>
{% endfor %}
</div>
</div>
<div class="card-footer d-flex gap-2">
<a href="{{ url_for('plans.edit', plan_id=plan.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('plans.toggle_active', plan_id=plan.id) }}" class="d-inline"
onsubmit="return confirm('Toggle plan status?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-{{ 'danger' if plan.is_active else 'success' }} btn-sm">
{{ 'Deactivate' if plan.is_active else 'Activate' }}
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Set Override — {{ tenant.name }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Set Setting Override — {{ tenant.name }}</h5></div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Setting Key</label>
<input type="text" class="form-control font-monospace" name="setting_key"
placeholder="e.g. feature_marketing or business_hours" required>
<div class="form-text">Alphanumeric, underscores, dots. Use <code>feature_FLAG</code> to force-enable/disable a plan feature.</div>
</div>
<div class="mb-3">
<label class="form-label">Value</label>
<input type="text" class="form-control" name="setting_value" required>
<div class="form-text">For feature flags: <code>true</code> or <code>false</code></div>
</div>
<div class="mb-3">
<label class="form-label">Note <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" name="note" placeholder="Reason for override">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Set Override</button>
<a href="{{ url_for('settings_override.tenant_overrides', tenant_id=tenant.id) }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,66 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Settings Overrides — {{ tenant.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">Overrides — {{ tenant.name }}</h4>
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-warning btn-sm">
<i class="bi bi-plus-lg me-1"></i>Set Override
</a>
</div>
{% if active %}
<div class="card shadow-sm mb-4 border-warning">
<div class="card-header bg-warning-subtle fw-semibold">Active Overrides</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Set at</th><th>Note</th><th></th></tr></thead>
<tbody>
{% for ov in active %}
<tr>
<td><code>{{ ov.setting_key }}</code></td>
<td>{{ ov.setting_value }}</td>
<td class="small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
<td class="small text-muted">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="small text-muted">{{ ov.note or '—' }}</td>
<td>
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
onsubmit="return confirm('Lift this override?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-warning btn-sm">Lift</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No active overrides for this tenant.</div>
{% endif %}
{% if history %}
<div class="card shadow-sm">
<div class="card-header fw-semibold text-muted">Override History</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Key</th><th>Value</th><th>Set at</th><th>Lifted at</th></tr></thead>
<tbody>
{% for ov in history %}
<tr class="text-muted">
<td><code>{{ ov.setting_key }}</code></td>
<td>{{ ov.setting_value }}</td>
<td class="small">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="small">{{ ov.lifted_at.strftime('%Y-%m-%d %H:%M') if ov.lifted_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
</a>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "admin/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} System User{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} System User</h5></div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ user.email if user else '' }}"
{{ 'readonly' if mode == 'edit' else '' }} required>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name"
value="{{ user.name if user else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select class="form-select" name="role">
<option value="superadmin" {{ 'selected' if user and user.role == 'superadmin' }}>superadmin</option>
</select>
</div>
{% if mode == 'create' %}
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="password" autocomplete="new-password" required>
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
</div>
{% else %}
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if user and user.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-dark">Save</button>
<a href="{{ url_for('system_users.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "admin/layouts/base.html" %}
{% block title %}System Users{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-people me-2"></i>System Users</h4>
<a href="{{ url_for('system_users.create') }}" class="btn btn-dark btn-sm">
<i class="bi bi-plus-lg me-1"></i>New User
</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Email</th><th>Name</th><th>Role</th><th>Status</th>
<th>Last Login</th><th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.email }}</td>
<td>{{ u.name }}</td>
<td><span class="badge bg-secondary">{{ u.role }}</span></td>
<td>
{% if u.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-danger">Inactive</span>
{% endif %}
{% if u.is_locked() %}
<span class="badge bg-warning text-dark">Locked</span>
{% endif %}
</td>
<td class="text-muted small">
{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') if u.last_login_at else 'Never' }}
</td>
<td>
<a href="{{ url_for('system_users.edit', user_id=u.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('system_users.toggle_active', user_id=u.id) }}" class="d-inline"
onsubmit="return confirm('Toggle active status?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-{{ 'danger' if u.is_active else 'success' }} btn-sm">
{{ 'Deactivate' if u.is_active else 'Activate' }}
</button>
</form>
<form method="POST" action="{{ url_for('system_users.force_password_reset', user_id=u.id) }}" class="d-inline"
onsubmit="return confirm('Send password reset email?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-warning btn-sm">Reset PW</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No system users found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "admin/layouts/base.html" %}
{% block title %}{{ tenant.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-bold mb-0">{{ tenant.name }}
<code class="fs-6 ms-2">{{ tenant.slug }}</code>
{% if tenant.is_demo %}<span class="badge bg-info ms-2">Demo</span>{% endif %}
</h4>
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(tenant.status,'secondary') }} mt-1">
{{ tenant.status }}
</span>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('tenants.edit', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-receipt me-1"></i>Add Billing Entry
</a>
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-outline-warning btn-sm">
<i class="bi bi-sliders me-1"></i>Set Override
</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card shadow-sm text-center py-3">
<div class="fs-4 fw-bold">{{ tenant.plan.name if tenant.plan else '—' }}</div>
<div class="text-muted small">Plan</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm text-center py-3">
<div class="fs-4 fw-bold">{{ locations|length }}</div>
<div class="text-muted small">Locations</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm text-center py-3">
<div class="fs-4 fw-bold">{{ users|length }}</div>
<div class="text-muted small">Portal Users</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm text-center py-3">
<div class="fs-4 fw-bold">{{ overrides|length }}</div>
<div class="text-muted small">Active Overrides</div>
</div>
</div>
</div>
<!-- Status change -->
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Change Status</div>
<div class="card-body">
<form method="POST" action="{{ url_for('tenants.set_status', tenant_id=tenant.id) }}"
class="d-flex gap-2" onsubmit="return confirm('Change tenant status?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select class="form-select form-select-sm w-auto" name="status">
{% for s in ['active', 'trial', 'suspended', 'cancelled'] %}
<option value="{{ s }}" {{ 'selected' if tenant.status == s }}>{{ s.capitalize() }}</option>
{% endfor %}
</select>
<button class="btn btn-dark btn-sm">Apply</button>
</form>
</div>
</div>
<!-- Active overrides -->
{% if overrides %}
<div class="card shadow-sm mb-3 border-warning">
<div class="card-header fw-semibold text-warning-emphasis bg-warning-subtle">
<i class="bi bi-sliders me-1"></i>Active Setting Overrides
</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Note</th><th></th></tr></thead>
<tbody>
{% for ov in overrides %}
<tr>
<td><code>{{ ov.setting_key }}</code></td>
<td>{{ ov.setting_value }}</td>
<td class="text-muted small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
<td class="text-muted small">{{ ov.note or '—' }}</td>
<td>
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
onsubmit="return confirm('Lift this override?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-warning btn-sm">Lift</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Billing history -->
{% if billing %}
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Recent Billing</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr></thead>
<tbody>
{% for b in billing %}
<tr>
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
<td>${{ "%.2f"|format(b.amount) }}</td>
<td>{{ b.description }}</td>
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
<td>{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Tenants
</a>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "admin/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New Tenant' }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">{{ 'Edit Tenant' if mode == 'edit' else 'New Tenant' }}</h5></div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Business Name</label>
<input type="text" class="form-control" name="name"
value="{{ tenant.name if tenant else '' }}" required>
</div>
{% if mode == 'create' %}
<div class="mb-3">
<label class="form-label">Slug <small class="text-muted">(URL-friendly, e.g. my-salon)</small></label>
<input type="text" class="form-control" name="slug" pattern="[a-z0-9\-]+"
placeholder="lowercase-letters-and-hyphens" required>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label">Owner Email</label>
<input type="email" class="form-control" name="owner_email"
value="{{ tenant.owner_email if tenant else '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<select class="form-select" name="plan_id" required>
<option value="">— Select a plan —</option>
{% for p in plans %}
<option value="{{ p.id }}" {{ 'selected' if tenant and tenant.plan_id == p.id }}>
{{ p.name }} (${{ "%.2f"|format(p.price_monthly) }}/mo)
</option>
{% endfor %}
</select>
</div>
{% if mode == 'create' %}
<div class="mb-3">
<label class="form-label">Trial Duration (days)</label>
<input type="number" class="form-control" name="trial_days" value="14" min="0" max="90">
</div>
<hr>
<h6 class="fw-semibold mb-3">Owner Account Password</h6>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="owner_password" autocomplete="new-password" required>
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
</div>
{% else %}
<div class="mb-3">
<label class="form-label">Subscription Expires At</label>
<input type="datetime-local" class="form-control" name="subscription_expires_at"
value="{{ tenant.subscription_expires_at.strftime('%Y-%m-%dT%H:%M') if tenant and tenant.subscription_expires_at else '' }}">
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_demo" value="1" id="is_demo"
{{ 'checked' if tenant and tenant.is_demo }}>
<label class="form-check-label" for="is_demo">Demo tenant (read-only)</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-dark">Save</button>
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "admin/layouts/base.html" %}
{% block title %}Tenants{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-building me-2"></i>Tenants</h4>
<a href="{{ url_for('tenants.create') }}" class="btn btn-dark btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Tenant
</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form method="GET" class="d-flex gap-2 align-items-center">
<label class="text-muted small me-1">Filter by status:</label>
{% for s in ['', 'active', 'trial', 'suspended', 'cancelled'] %}
<a href="{{ url_for('tenants.index', status=s) }}"
class="btn btn-sm {{ 'btn-dark' if status_filter == s else 'btn-outline-secondary' }}">
{{ s.capitalize() if s else 'All' }}
</a>
{% endfor %}
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Slug</th><th>Name</th><th>Plan</th><th>Status</th>
<th>Owner</th><th>Created</th><th>Actions</th>
</tr>
</thead>
<tbody>
{% for t in tenants %}
<tr>
<td><code>{{ t.slug }}</code>{% if t.is_demo %}<span class="badge bg-info ms-1">Demo</span>{% endif %}</td>
<td>{{ t.name }}</td>
<td>{{ t.plan.name if t.plan else '—' }}</td>
<td>
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(t.status,'secondary') }}">
{{ t.status }}
</span>
</td>
<td class="text-muted small">{{ t.owner_email }}</td>
<td class="text-muted small">{{ t.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('tenants.detail', tenant_id=t.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No tenants found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+292
View File
@@ -0,0 +1,292 @@
"""
tests/test_admin_phase2.py
Phase 2 admin portal tests: system users, plans, tenants,
billing, settings override, audit log, analytics.
Uses the testing app with SQLite in-memory DB.
"""
import pytest
from app.extensions import db as _db, bcrypt as _bcrypt
from app.models.platform import SystemUser, Plan, Tenant, TenantBillingHistory, TenantSettingOverride, AuditLog
# ── Helpers ───────────────────────────────────────────────────────────────────
def _login_admin(client, email="admin@test.com", password="AdminPass1A"):
return client.post("/login", data={"email": email, "password": password},
follow_redirects=True)
def _create_admin(app, email="admin@test.com", password="AdminPass1A"):
with app.app_context():
if SystemUser.query.filter_by(email=email).first():
return
u = SystemUser(
email=email,
password_hash=_bcrypt.generate_password_hash(password).decode("utf-8"),
name="Test Admin", role="superadmin", is_active=True,
)
_db.session.add(u)
_db.session.commit()
def _create_tenant(app, slug="test-salon"):
with app.app_context():
plan = Plan.query.first()
if Tenant.query.filter_by(slug=slug).first():
return
t = Tenant(slug=slug, name="Test Salon", owner_email="owner@test.com",
plan_id=plan.id, status="trial")
_db.session.add(t)
_db.session.commit()
return t.id
# ── System Users ──────────────────────────────────────────────────────────────
class TestSystemUsers:
def test_index_requires_login(self, admin_client):
resp = admin_client.get("/system-users/", follow_redirects=False)
assert resp.status_code in (302, 401)
def test_index_accessible_to_admin(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/system-users/")
assert resp.status_code == 200
def test_create_system_user(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.post("/system-users/new", data={
"email": "newadmin@test.com", "name": "New Admin",
"role": "superadmin", "password": "NewAdmin1A",
"confirm_password": "NewAdmin1A",
}, follow_redirects=True)
assert resp.status_code == 200
with admin_app.app_context():
assert SystemUser.query.filter_by(email="newadmin@test.com").first()
def test_create_user_weak_password_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.post("/system-users/new", data={
"email": "weak@test.com", "name": "Weak", "role": "superadmin",
"password": "weak", "confirm_password": "weak",
}, follow_redirects=True)
assert b"password" in resp.data.lower()
with admin_app.app_context():
assert SystemUser.query.filter_by(email="weak@test.com").first() is None
def test_create_user_duplicate_email_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.post("/system-users/new", data={
"email": "admin@test.com", "name": "Dupe", "role": "superadmin",
"password": "AdminPass1A", "confirm_password": "AdminPass1A",
}, follow_redirects=True)
assert b"already exists" in resp.data
# ── Plans ─────────────────────────────────────────────────────────────────────
class TestPlans:
def test_plans_index(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/plans/")
assert resp.status_code == 200
assert b"Starter" in resp.data # seeded in conftest
def test_create_plan(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.post("/plans/new", data={
"name": "Enterprise", "price_monthly": "199.00",
"max_staff": "", "max_locations": "",
"feature_pos": "1", "feature_marketing": "1",
"is_active": "1",
}, follow_redirects=True)
assert resp.status_code == 200
with admin_app.app_context():
assert Plan.query.filter_by(name="Enterprise").first()
def test_plan_missing_name_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.post("/plans/new", data={
"name": "", "price_monthly": "99",
}, follow_redirects=True)
assert b"required" in resp.data.lower()
# ── Tenants ───────────────────────────────────────────────────────────────────
class TestTenants:
def test_tenants_index(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/tenants/")
assert resp.status_code == 200
def test_create_tenant(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
with admin_app.app_context():
plan = Plan.query.first()
resp = admin_client.post("/tenants/new", data={
"slug": "brand-new-salon", "name": "Brand New Salon",
"owner_email": "owner@brandnew.com", "plan_id": str(plan.id),
"trial_days": "14",
"owner_password": "OwnerPass1A", "confirm_password": "OwnerPass1A",
}, follow_redirects=True)
assert resp.status_code == 200
with admin_app.app_context():
assert Tenant.query.filter_by(slug="brand-new-salon").first()
def test_duplicate_slug_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
_create_tenant(admin_app, slug="dup-slug")
_login_admin(admin_client)
with admin_app.app_context():
plan = Plan.query.first()
resp = admin_client.post("/tenants/new", data={
"slug": "dup-slug", "name": "Dup", "owner_email": "dup@test.com",
"plan_id": str(plan.id), "owner_password": "OwnerPass1A",
"confirm_password": "OwnerPass1A",
}, follow_redirects=True)
assert b"already taken" in resp.data
def test_invalid_slug_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
with admin_app.app_context():
plan = Plan.query.first()
resp = admin_client.post("/tenants/new", data={
"slug": "INVALID SLUG!", "name": "Bad", "owner_email": "bad@test.com",
"plan_id": str(plan.id), "owner_password": "OwnerPass1A",
"confirm_password": "OwnerPass1A",
}, follow_redirects=True)
assert b"slug" in resp.data.lower()
def test_tenant_detail_accessible(self, admin_app, admin_client):
_create_admin(admin_app)
tid = _create_tenant(admin_app, slug="detail-salon")
_login_admin(admin_client)
resp = admin_client.get(f"/tenants/{tid}")
assert resp.status_code == 200
def test_set_status_suspended(self, admin_app, admin_client):
_create_admin(admin_app)
tid = _create_tenant(admin_app, slug="suspend-salon")
_login_admin(admin_client)
resp = admin_client.post(f"/tenants/{tid}/set-status",
data={"status": "suspended"}, follow_redirects=True)
assert resp.status_code == 200
with admin_app.app_context():
t = Tenant.query.get(tid)
assert t.status == "suspended"
# ── Settings Override ─────────────────────────────────────────────────────────
class TestSettingsOverride:
def test_set_override(self, admin_app, admin_client):
_create_admin(admin_app)
tid = _create_tenant(admin_app, slug="override-salon")
_login_admin(admin_client)
resp = admin_client.post(
f"/settings-override/tenant/{tid}/set",
data={"setting_key": "feature_marketing", "setting_value": "true", "note": "Test override"},
follow_redirects=True,
)
assert resp.status_code == 200
with admin_app.app_context():
ov = TenantSettingOverride.query.filter_by(
tenant_id=tid, setting_key="feature_marketing"
).filter(TenantSettingOverride.lifted_at.is_(None)).first()
assert ov is not None
assert ov.setting_value == "true"
def test_lift_override(self, admin_app, admin_client):
_create_admin(admin_app)
tid = _create_tenant(admin_app, slug="lift-salon")
_login_admin(admin_client)
# Set first
admin_client.post(
f"/settings-override/tenant/{tid}/set",
data={"setting_key": "feature_inventory", "setting_value": "false"},
follow_redirects=True,
)
with admin_app.app_context():
ov = TenantSettingOverride.query.filter_by(
tenant_id=tid, setting_key="feature_inventory"
).first()
ov_id = ov.id
# Lift
resp = admin_client.post(f"/settings-override/{ov_id}/lift", follow_redirects=True)
assert resp.status_code == 200
with admin_app.app_context():
ov = TenantSettingOverride.query.get(ov_id)
assert ov.lifted_at is not None
def test_invalid_setting_key_rejected(self, admin_app, admin_client):
_create_admin(admin_app)
tid = _create_tenant(admin_app, slug="badkey-salon")
_login_admin(admin_client)
resp = admin_client.post(
f"/settings-override/tenant/{tid}/set",
data={"setting_key": "bad key!", "setting_value": "true"},
follow_redirects=True,
)
assert b"Invalid setting key" in resp.data
# ── Audit Log ─────────────────────────────────────────────────────────────────
class TestAuditLog:
def test_audit_log_accessible(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/audit-log/")
assert resp.status_code == 200
def test_actions_are_logged(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
# Create a plan — should produce an audit log entry
admin_client.post("/plans/new", data={
"name": "AuditTestPlan", "price_monthly": "49.00",
"is_active": "1",
}, follow_redirects=True)
with admin_app.app_context():
entry = AuditLog.query.filter(
AuditLog.action == "plan.create"
).first()
assert entry is not None
def test_audit_log_csv_export(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/audit-log/export.csv")
assert resp.status_code == 200
assert resp.content_type == "text/csv; charset=utf-8"
# ── Analytics ─────────────────────────────────────────────────────────────────
class TestAnalytics:
def test_analytics_dashboard_loads(self, admin_app, admin_client):
_create_admin(admin_app)
_login_admin(admin_client)
resp = admin_client.get("/analytics/")
assert resp.status_code == 200
assert b"Platform Analytics" in resp.data
def test_analytics_shows_tenant_counts(self, admin_app, admin_client):
_create_admin(admin_app)
_create_tenant(admin_app, slug="analytics-salon")
_login_admin(admin_client)
resp = admin_client.get("/analytics/")
assert resp.status_code == 200
assert b"Total Tenants" in resp.data