05/06 Phase 2: updated and added new files
This commit is contained in:
+10
-3
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user