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
+10 -3
View File
@@ -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
+109 -2
View File
@@ -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,
)
+112 -2
View File
@@ -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"},
)
+1 -3
View File
@@ -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")
+87 -2
View File
@@ -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/<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
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("/<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
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/<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
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("/<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
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("/<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:
"""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)