From 8da0c11290c30e71874030c9cbb9867390cc095b Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 6 May 2026 17:40:21 -0400 Subject: [PATCH] 05/06 Phase 2: updated and added new files --- CLAUDE.md | 23 +- app/admin/__init__.py | 13 +- app/admin/analytics/routes.py | 111 +++++++- app/admin/audit_log/routes.py | 114 +++++++- app/admin/auth/routes.py | 4 +- app/admin/billing/routes.py | 89 +++++- app/admin/plans/routes.py | 123 ++++++++- app/admin/settings_override/routes.py | 119 +++++++- app/admin/system_users/routes.py | 140 +++++++++- app/admin/tenants/routes.py | 174 +++++++++++- app/admin/utils.py | 55 ++++ app/security.py | 6 + templates/admin/analytics/index.html | 84 ++++++ templates/admin/audit_log/index.html | 88 ++++++ templates/admin/billing/form.html | 37 +++ templates/admin/billing/index.html | 32 +++ templates/admin/billing/tenant.html | 35 +++ templates/admin/layouts/base.html | 12 +- templates/admin/plans/form.html | 65 +++++ templates/admin/plans/index.html | 48 ++++ templates/admin/settings_override/form.html | 37 +++ templates/admin/settings_override/list.html | 66 +++++ templates/admin/system_users/form.html | 57 ++++ templates/admin/system_users/index.html | 61 ++++ templates/admin/tenants/detail.html | 128 +++++++++ templates/admin/tenants/form.html | 77 ++++++ templates/admin/tenants/index.html | 58 ++++ tests/test_admin_phase2.py | 292 ++++++++++++++++++++ 28 files changed, 2113 insertions(+), 35 deletions(-) create mode 100644 app/admin/utils.py create mode 100644 templates/admin/analytics/index.html create mode 100644 templates/admin/audit_log/index.html create mode 100644 templates/admin/billing/form.html create mode 100644 templates/admin/billing/index.html create mode 100644 templates/admin/billing/tenant.html create mode 100644 templates/admin/plans/form.html create mode 100644 templates/admin/plans/index.html create mode 100644 templates/admin/settings_override/form.html create mode 100644 templates/admin/settings_override/list.html create mode 100644 templates/admin/system_users/form.html create mode 100644 templates/admin/system_users/index.html create mode 100644 templates/admin/tenants/detail.html create mode 100644 templates/admin/tenants/form.html create mode 100644 templates/admin/tenants/index.html create mode 100644 tests/test_admin_phase2.py diff --git a/CLAUDE.md b/CLAUDE.md index c32c947..aff583f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -668,14 +668,19 @@ WantedBy=multi-user.target - [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) -### Phase 2 — Admin Portal -- [ ] System user management (CRUD, force password reset) -- [ ] Tenant management (create, edit, suspend, cancel, assign plan) -- [ ] Plan management (create/edit, feature flags, limits) -- [ ] Billing history (manual invoice entry, per-tenant view) -- [ ] Tenant settings override (set/lift with before/after audit trail) -- [ ] Audit log viewer (filter by actor, action, date; export) -- [ ] Platform analytics dashboard +### Phase 2 — Admin Portal ✅ COMPLETE +- [x] System user management (CRUD, force password reset, activate/deactivate) +- [x] Tenant management (create with owner account + primary location, edit, suspend/cancel/activate, assign plan) +- [x] Plan management (create/edit, feature flags via checkboxes, max staff/locations, activate/deactivate) +- [x] Billing history (manual invoice entry per tenant, global list view, per-tenant view) +- [x] Tenant settings override (set with note, lift individually, history view, before/after audit trail) +- [x] Audit log viewer (filter by actor/action/target/date, paginated, CSV export) +- [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 - [ ] Location management (CRUD, primary flag, per-location settings) @@ -947,4 +952,4 @@ All Phase 2+ blueprints are registered as stubs (blueprint object only, no route | 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). | | 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. | | 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. | -| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. | \ No newline at end of file +| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. | diff --git a/app/admin/__init__.py b/app/admin/__init__.py index 20aadcb..e3dd691 100644 --- a/app/admin/__init__.py +++ b/app/admin/__init__.py @@ -71,10 +71,17 @@ def create_admin_app(config_override=None): # ── Blueprints ──────────────────────────────────────────── 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: - # system_users, tenants, plans, billing, settings_override, audit_log, analytics + for bp in [admin_auth_bp, system_users_bp, tenants_bp, plans_bp, + billing_bp, settings_override_bp, audit_log_bp, analytics_bp]: + flask_app.register_blueprint(bp) # ── Import all models for Migrate ───────────────────────── import app.models # noqa: F401 diff --git a/app/admin/analytics/routes.py b/app/admin/analytics/routes.py index 4312969..a28d856 100644 --- a/app/admin/analytics/routes.py +++ b/app/admin/analytics/routes.py @@ -1,7 +1,114 @@ """ 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.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, + ) diff --git a/app/admin/audit_log/routes.py b/app/admin/audit_log/routes.py index 9d0638b..b062a27 100644 --- a/app/admin/audit_log/routes.py +++ b/app/admin/audit_log/routes.py @@ -1,7 +1,117 @@ """ 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") +_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"}, + ) diff --git a/app/admin/auth/routes.py b/app/admin/auth/routes.py index 2f38741..ab725a4 100644 --- a/app/admin/auth/routes.py +++ b/app/admin/auth/routes.py @@ -75,9 +75,7 @@ def login(): @admin_auth_bp.route("/dashboard") @login_required def dashboard_redirect(): - # Phase 2: redirect to analytics/tenants dashboard - flash("Welcome to the Admin Portal. Feature modules coming in Phase 2.", "info") - return render_template("admin/auth/login.html", error=None) + return redirect(url_for("analytics.index")) @admin_auth_bp.route("/logout") diff --git a/app/admin/billing/routes.py b/app/admin/billing/routes.py index e1a6174..1bd8cd3 100644 --- a/app/admin/billing/routes.py +++ b/app/admin/billing/routes.py @@ -1,7 +1,92 @@ """ 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.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/") +@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//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) diff --git a/app/admin/plans/routes.py b/app/admin/plans/routes.py index 5371802..5f8d5d4 100644 --- a/app/admin/plans/routes.py +++ b/app/admin/plans/routes.py @@ -1,7 +1,126 @@ """ 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") + +_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("//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("//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 diff --git a/app/admin/settings_override/routes.py b/app/admin/settings_override/routes.py index 4857ce1..15b2fe1 100644 --- a/app/admin/settings_override/routes.py +++ b/app/admin/settings_override/routes.py @@ -1,7 +1,122 @@ """ 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.route("/tenant/") +@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//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("//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)) diff --git a/app/admin/system_users/routes.py b/app/admin/system_users/routes.py index 4009ee9..b8cc1f2 100644 --- a/app/admin/system_users/routes.py +++ b/app/admin/system_users/routes.py @@ -1,7 +1,143 @@ """ 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") +_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("//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("//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("//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")) diff --git a/app/admin/tenants/routes.py b/app/admin/tenants/routes.py index b01e136..0bfb8c6 100644 --- a/app/admin/tenants/routes.py +++ b/app/admin/tenants/routes.py @@ -1,7 +1,177 @@ """ 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") +_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("/") +@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("//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("//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)) diff --git a/app/admin/utils.py b/app/admin/utils.py new file mode 100644 index 0000000..7444dc4 --- /dev/null +++ b/app/admin/utils.py @@ -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} diff --git a/app/security.py b/app/security.py index ab43ecf..ff4fd93 100644 --- a/app/security.py +++ b/app/security.py @@ -98,3 +98,9 @@ def sanitise_string(value: str, max_length: int = None) -> str: def validate_slug(slug: str) -> bool: """Return True if the slug matches the allowed pattern.""" 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) diff --git a/templates/admin/analytics/index.html b/templates/admin/analytics/index.html new file mode 100644 index 0000000..e8697db --- /dev/null +++ b/templates/admin/analytics/index.html @@ -0,0 +1,84 @@ +{% extends "admin/layouts/base.html" %} +{% block title %}Platform Analytics{% endblock %} +{% block content %} +

Platform Analytics

+ +
+ {% 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 %} +
+
+
+
+ +
+
+
{{ value }}
+
{{ label }}
+
+
+
+
+ {% endfor %} +
+ +
+ +
+
+
Active Tenants by Plan
+
+ {% for plan_name, count in stats.plan_counts %} +
+ {{ plan_name }} + {{ count }} +
+ {% else %} +

No data yet.

+ {% endfor %} +
+
+
+ + +
+
+
+ Trials Expiring in 3 Days +
+ {% if expiring_soon %} +
+ + + + {% for t in expiring_soon %} + + + + + + + {% endfor %} + +
TenantPlanTrial Ends
{{ t.name }}{{ t.plan.name if t.plan else '—' }}{{ t.trial_ends_at.strftime('%Y-%m-%d %H:%M') }} + Convert +
+
+ {% else %} +
No trials expiring in the next 3 days.
+ {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/audit_log/index.html b/templates/admin/audit_log/index.html new file mode 100644 index 0000000..f1fddeb --- /dev/null +++ b/templates/admin/audit_log/index.html @@ -0,0 +1,88 @@ +{% extends "admin/layouts/base.html" %} +{% block title %}Audit Log{% endblock %} +{% block content %} +
+

Audit Log

+ + Export CSV + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+ + + + + + {% for e in entries %} + + + + + + + + {% else %} + + {% endfor %} + +
TimeActorActionTargetIP
{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') }}{{ e.actor_type }}:{{ e.actor_id }}{{ e.action }}{{ (e.target_type ~ ':' ~ e.target_id) if e.target_type else '—' }}{{ e.ip_address or '—' }}
No entries match your filter.
+
+
+ +{% if pagination.pages > 1 %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/admin/billing/form.html b/templates/admin/billing/form.html new file mode 100644 index 0000000..9ac8568 --- /dev/null +++ b/templates/admin/billing/form.html @@ -0,0 +1,37 @@ +{% extends "admin/layouts/base.html" %} +{% block title %}Add Billing Entry{% endblock %} +{% block content %} +
+
+
+
Add Billing Entry — {{ tenant.name }}
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/billing/index.html b/templates/admin/billing/index.html new file mode 100644 index 0000000..50537eb --- /dev/null +++ b/templates/admin/billing/index.html @@ -0,0 +1,32 @@ +{% extends "admin/layouts/base.html" %} +{% block title %}Billing History{% endblock %} +{% block content %} +

Billing History

+
+
+ + + + + + {% for b in entries %} + + + + + + + + + {% else %} + + {% endfor %} + +
DateTenantAmountDescriptionInvoice RefPaid
{{ b.created_at.strftime('%Y-%m-%d') }} + + {{ b.tenant.name if b.tenant else b.tenant_id }} + + ${{ "%.2f"|format(b.amount) }}{{ b.description }}{{ b.invoice_ref or '—' }}{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}
No billing entries yet.
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/billing/tenant.html b/templates/admin/billing/tenant.html new file mode 100644 index 0000000..c7607a9 --- /dev/null +++ b/templates/admin/billing/tenant.html @@ -0,0 +1,35 @@ +{% extends "admin/layouts/base.html" %} +{% block title %}Billing — {{ tenant.name }}{% endblock %} +{% block content %} +
+

Billing — {{ tenant.name }}

+ + Add Entry + +
+
+
+ + + + + + {% for b in entries %} + + + + + + + + {% else %} + + {% endfor %} + +
DateAmountDescriptionInvoice RefPaid
{{ b.created_at.strftime('%Y-%m-%d') }}${{ "%.2f"|format(b.amount) }}{{ b.description }}{{ b.invoice_ref or '—' }}{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}
No entries yet.
+
+
+ + Back to Tenant + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/layouts/base.html b/templates/admin/layouts/base.html index 11e3ce7..5f6bb32 100644 --- a/templates/admin/layouts/base.html +++ b/templates/admin/layouts/base.html @@ -32,37 +32,37 @@