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