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