178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""
|
|
app/admin/tenants/routes.py
|
|
Tenant management: create, view, edit, suspend, cancel, assign plan.
|
|
All status changes logged. Superadmin only.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
|
from flask_login import login_required
|
|
|
|
from app.extensions import db, bcrypt
|
|
from app.models.platform import Tenant, Plan
|
|
from app.models.salon import User, Location
|
|
from app.admin.utils import superadmin_required, log_admin_action, model_to_dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
|
|
_AUDIT_FIELDS = ["slug", "name", "owner_email", "plan_id", "status", "is_demo",
|
|
"trial_ends_at", "subscription_expires_at"]
|
|
|
|
|
|
@tenants_bp.route("/")
|
|
@login_required
|
|
@superadmin_required
|
|
def index():
|
|
status_filter = request.args.get("status", "")
|
|
q = Tenant.query.order_by(Tenant.created_at.desc())
|
|
if status_filter:
|
|
q = q.filter_by(status=status_filter)
|
|
tenants = q.all()
|
|
return render_template("admin/tenants/index.html", tenants=tenants,
|
|
status_filter=status_filter)
|
|
|
|
|
|
@tenants_bp.route("/<int:tenant_id>")
|
|
@login_required
|
|
@superadmin_required
|
|
def detail(tenant_id):
|
|
tenant = Tenant.query.get_or_404(tenant_id)
|
|
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
|
|
locations = Location.query.filter_by(tenant_id=tenant_id).all()
|
|
users = User.query.filter_by(tenant_id=tenant_id).all()
|
|
from app.models.platform import TenantBillingHistory, TenantSettingOverride
|
|
billing = TenantBillingHistory.query.filter_by(
|
|
tenant_id=tenant_id).order_by(TenantBillingHistory.created_at.desc()).limit(20).all()
|
|
overrides = TenantSettingOverride.query.filter_by(
|
|
tenant_id=tenant_id).filter(TenantSettingOverride.lifted_at.is_(None)).all()
|
|
return render_template(
|
|
"admin/tenants/detail.html", tenant=tenant, plans=plans,
|
|
locations=locations, users=users, billing=billing, overrides=overrides,
|
|
)
|
|
|
|
|
|
@tenants_bp.route("/new", methods=["GET", "POST"])
|
|
@login_required
|
|
@superadmin_required
|
|
def create():
|
|
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
|
|
|
|
if request.method == "POST":
|
|
slug = request.form.get("slug", "").strip().lower()
|
|
name = request.form.get("name", "").strip()
|
|
owner_email = request.form.get("owner_email", "").strip().lower()
|
|
plan_id = request.form.get("plan_id", type=int)
|
|
owner_password = request.form.get("owner_password", "")
|
|
confirm = request.form.get("confirm_password", "")
|
|
trial_days = request.form.get("trial_days", "14")
|
|
|
|
from app.security import validate_slug
|
|
if not validate_slug(slug):
|
|
return render_template("admin/tenants/form.html", mode="create",
|
|
plans=plans, error="Invalid slug (lowercase letters, digits, hyphens only).")
|
|
if Tenant.query.filter_by(slug=slug).first():
|
|
return render_template("admin/tenants/form.html", mode="create",
|
|
plans=plans, error="That slug is already taken.")
|
|
from app.forms import validate_password_strength
|
|
err = validate_password_strength(owner_password, confirm)
|
|
if err:
|
|
return render_template("admin/tenants/form.html", mode="create",
|
|
plans=plans, error=err)
|
|
if not plan_id or not Plan.query.get(plan_id):
|
|
return render_template("admin/tenants/form.html", mode="create",
|
|
plans=plans, error="Please select a valid plan.")
|
|
|
|
try:
|
|
days = int(trial_days)
|
|
except ValueError:
|
|
days = 14
|
|
|
|
tenant = Tenant(
|
|
slug=slug, name=name, owner_email=owner_email,
|
|
plan_id=plan_id, status="trial",
|
|
trial_ends_at=datetime.now(timezone.utc) + timedelta(days=days),
|
|
)
|
|
db.session.add(tenant)
|
|
db.session.flush()
|
|
|
|
# Primary location
|
|
location = Location(
|
|
tenant_id=tenant.id, name=name,
|
|
is_primary=True, is_active=True,
|
|
)
|
|
db.session.add(location)
|
|
|
|
# Owner user
|
|
owner = User(
|
|
tenant_id=tenant.id, email=owner_email, role="tenant_admin",
|
|
password_hash=bcrypt.generate_password_hash(owner_password).decode("utf-8"),
|
|
is_active=True,
|
|
)
|
|
db.session.add(owner)
|
|
|
|
log_admin_action("tenant.create", "tenant", tenant.id,
|
|
after=model_to_dict(tenant, _AUDIT_FIELDS))
|
|
db.session.commit()
|
|
logger.info("Tenant created: id=%s slug=%s", tenant.id, slug)
|
|
flash(f"Tenant \'{name}\' created (slug: {slug}).", "success")
|
|
return redirect(url_for("tenants.detail", tenant_id=tenant.id))
|
|
|
|
return render_template("admin/tenants/form.html", mode="create", plans=plans)
|
|
|
|
|
|
@tenants_bp.route("/<int:tenant_id>/edit", methods=["GET", "POST"])
|
|
@login_required
|
|
@superadmin_required
|
|
def edit(tenant_id):
|
|
tenant = Tenant.query.get_or_404(tenant_id)
|
|
plans = Plan.query.filter_by(is_active=True).order_by(Plan.price_monthly).all()
|
|
before = model_to_dict(tenant, _AUDIT_FIELDS)
|
|
|
|
if request.method == "POST":
|
|
tenant.name = request.form.get("name", tenant.name).strip()
|
|
tenant.owner_email = request.form.get("owner_email", tenant.owner_email).strip().lower()
|
|
new_plan_id = request.form.get("plan_id", type=int)
|
|
if new_plan_id and Plan.query.get(new_plan_id):
|
|
tenant.plan_id = new_plan_id
|
|
sub_expires_raw = request.form.get("subscription_expires_at", "").strip()
|
|
if sub_expires_raw:
|
|
try:
|
|
tenant.subscription_expires_at = datetime.fromisoformat(sub_expires_raw)
|
|
except ValueError:
|
|
pass
|
|
tenant.is_demo = request.form.get("is_demo") == "1"
|
|
|
|
log_admin_action("tenant.edit", "tenant", tenant.id,
|
|
before=before, after=model_to_dict(tenant, _AUDIT_FIELDS))
|
|
db.session.commit()
|
|
logger.info("Tenant edited: id=%s slug=%s", tenant.id, tenant.slug)
|
|
flash(f"Tenant \'{tenant.name}\' updated.", "success")
|
|
return redirect(url_for("tenants.detail", tenant_id=tenant.id))
|
|
|
|
return render_template("admin/tenants/form.html", mode="edit",
|
|
tenant=tenant, plans=plans)
|
|
|
|
|
|
@tenants_bp.route("/<int:tenant_id>/set-status", methods=["POST"])
|
|
@login_required
|
|
@superadmin_required
|
|
def set_status(tenant_id):
|
|
tenant = Tenant.query.get_or_404(tenant_id)
|
|
new_status = request.form.get("status", "").strip()
|
|
allowed = {"active", "trial", "suspended", "cancelled"}
|
|
if new_status not in allowed:
|
|
flash("Invalid status.", "danger")
|
|
return redirect(url_for("tenants.detail", tenant_id=tenant_id))
|
|
|
|
before = model_to_dict(tenant, _AUDIT_FIELDS)
|
|
tenant.status = new_status
|
|
log_admin_action(f"tenant.set_status.{new_status}", "tenant", tenant.id,
|
|
before=before, after=model_to_dict(tenant, _AUDIT_FIELDS))
|
|
db.session.commit()
|
|
logger.info("Tenant status changed: id=%s slug=%s status=%s", tenant.id, tenant.slug, new_status)
|
|
flash(f"Tenant \'{tenant.name}\' status set to \'{new_status}\'.", "success")
|
|
return redirect(url_for("tenants.detail", tenant_id=tenant_id))
|