"""Admin backend: dashboard KPIs + user management. Admin-only.""" import json from flask import Blueprint, render_template, request, redirect, url_for, flash, abort from flask_login import current_user from flask_babel import gettext as _ from app.extensions import db from app.models.user import User from app.models.listing import Listing from app.models.plan import Plan from app.models.category import Category from app.models.trust import TrustEvent from app.models.audit import AuditLog from app.models.report import Report from app.models.payments import Transaction from app.models.enums import UserStatus, TrustEventType, ListingStatus from app.utils import admin_required from app.services import admin_users as usvc from app.services import admin_dashboard as dash from app.services import moderation as msvc from app.services.settings import get_setting, set_setting from app.services import audit admin_bp = Blueprint("admin", __name__) PER_PAGE = 25 # --------------------------------------------------------------------------- # Dashboard # --------------------------------------------------------------------------- @admin_bp.route("/admin") @admin_required def dashboard(): kpis = { "active_listings": dash.active_listings_count(), "new_users_7d": dash.new_users_count(7), "new_users_30d": dash.new_users_count(30), "mrr_cents": dash.mrr_cents(), "revenue_30d_cents": dash.revenue_30d_cents(), "flag_queue_depth": dash.flag_queue_depth(), } return render_template("admin/dashboard.html", kpis=kpis) # --------------------------------------------------------------------------- # User management # --------------------------------------------------------------------------- @admin_bp.route("/admin/users") @admin_required def users(): page = request.args.get("page", 1, type=int) role = request.args.get("role", type=str) or None status = request.args.get("status", type=str) or None q = request.args.get("q", type=str) or None pagination = usvc.search_query(role=role, status=status, q=q).paginate( page=page, per_page=PER_PAGE, error_out=False) return render_template("admin/users.html", pagination=pagination, filters=request.args) @admin_bp.route("/admin/users/") @admin_required def user_detail(user_id): user = db.get_or_404(User, user_id) listings = (Listing.query.filter_by(user_id=user.id) .order_by(Listing.created_at.desc()).limit(50).all()) trust_events = (user.trust_events.order_by(TrustEvent.created_at.desc()) .limit(50).all()) audit_history = (AuditLog.query.filter_by(target_type="user", target_id=user.id) .order_by(AuditLog.created_at.desc()).limit(50).all()) plans = Plan.query.order_by(Plan.sort_order).all() return render_template("admin/user_detail.html", user=user, listings=listings, trust_events=trust_events, audit_history=audit_history, plans=plans, TrustEventType=TrustEventType) def _guard_self_action(user): if user.id == current_user.id: flash(_("You cannot perform this action on your own account."), "danger") return True return False @admin_bp.route("/admin/users//ban", methods=["POST"]) @admin_required def ban_user(user_id): user = db.get_or_404(User, user_id) if _guard_self_action(user): return redirect(url_for("admin.user_detail", user_id=user.id)) usvc.set_status(user, UserStatus.banned, actor=current_user) db.session.commit() flash(_("User banned."), "warning") return redirect(url_for("admin.user_detail", user_id=user.id)) @admin_bp.route("/admin/users//suspend", methods=["POST"]) @admin_required def suspend_user(user_id): user = db.get_or_404(User, user_id) if _guard_self_action(user): return redirect(url_for("admin.user_detail", user_id=user.id)) usvc.set_status(user, UserStatus.suspended, actor=current_user) db.session.commit() flash(_("User suspended."), "warning") return redirect(url_for("admin.user_detail", user_id=user.id)) @admin_bp.route("/admin/users//activate", methods=["POST"]) @admin_required def activate_user(user_id): user = db.get_or_404(User, user_id) usvc.set_status(user, UserStatus.active, actor=current_user) db.session.commit() flash(_("User activated."), "success") return redirect(url_for("admin.user_detail", user_id=user.id)) @admin_bp.route("/admin/users//tier", methods=["POST"]) @admin_required def set_tier(user_id): user = db.get_or_404(User, user_id) plan_id = request.form.get("plan_id", type=int) plan = db.session.get(Plan, plan_id) if plan_id else None usvc.set_tier(user, plan, actor=current_user) db.session.commit() flash(_("Tier updated."), "success") return redirect(url_for("admin.user_detail", user_id=user.id)) @admin_bp.route("/admin/users//trust", methods=["POST"]) @admin_required def trust_adjust(user_id): user = db.get_or_404(User, user_id) delta = request.form.get("delta", type=int) or 0 event_type = request.form.get("event_type", type=str) usvc.adjust_trust(user, TrustEventType(event_type), delta, actor=current_user) db.session.commit() flash(_("Trust adjusted."), "success") return redirect(url_for("admin.user_detail", user_id=user.id)) # --------------------------------------------------------------------------- # Listing moderation # --------------------------------------------------------------------------- @admin_bp.route("/admin/listings") @admin_required def listings(): page = request.args.get("page", 1, type=int) pagination = msvc.flag_queue(page=page, per_page=PER_PAGE) flag_threshold = get_setting("flag_threshold", 5) keyword_blocklist = get_setting("keyword_blocklist", []) return render_template("admin/listings.html", pagination=pagination, flag_threshold=flag_threshold, keyword_blocklist=keyword_blocklist) @admin_bp.route("/admin/listings//approve", methods=["POST"]) @admin_required def approve_listing(listing_id): listing = db.get_or_404(Listing, listing_id) msvc.approve(listing, actor=current_user) db.session.commit() flash(_("Listing approved."), "success") return redirect(url_for("admin.listings")) @admin_bp.route("/admin/listings//hide", methods=["POST"]) @admin_required def hide_listing(listing_id): listing = db.get_or_404(Listing, listing_id) msvc.hide(listing, actor=current_user) db.session.commit() flash(_("Listing hidden."), "warning") return redirect(url_for("admin.listings")) @admin_bp.route("/admin/listings//remove", methods=["POST"]) @admin_required def remove_listing(listing_id): listing = db.get_or_404(Listing, listing_id) msvc.remove(listing, actor=current_user) db.session.commit() flash(_("Listing removed."), "danger") return redirect(url_for("admin.listings")) # --------------------------------------------------------------------------- # Reports queue # --------------------------------------------------------------------------- @admin_bp.route("/admin/reports") @admin_required def reports(): dismissed_subq = ( db.session.query(AuditLog.target_id) .filter(AuditLog.target_type == "report", AuditLog.action == "report.dismissed") .scalar_subquery() ) open_reports = ( Report.query .join(Listing, Report.listing_id == Listing.id) .filter( Listing.status.in_([ListingStatus.flagged, ListingStatus.active]), Report.id.not_in(dismissed_subq), ) .order_by(Report.created_at.desc()) .all() ) return render_template("admin/reports.html", reports=open_reports) @admin_bp.route("/admin/reports//dismiss", methods=["POST"]) @admin_required def dismiss_report(report_id): report = db.get_or_404(Report, report_id) audit.log_action(current_user, "report.dismissed", "report", report.id, meta={"listing_id": report.listing_id, "reporter_id": report.reporter_id}) db.session.commit() flash(_("Report dismissed."), "info") return redirect(url_for("admin.reports")) @admin_bp.route("/admin/reports//escalate", methods=["POST"]) @admin_required def escalate_report(report_id): report = db.get_or_404(Report, report_id) audit.log_action(current_user, "report.escalated", "report", report.id, meta={"listing_id": report.listing_id, "reporter_id": report.reporter_id}) db.session.commit() flash(_("Report escalated."), "warning") return redirect(url_for("admin.reports")) # --------------------------------------------------------------------------- # General settings # --------------------------------------------------------------------------- @admin_bp.route("/admin/settings", methods=["GET", "POST"]) @admin_required def settings(): if request.method == "POST": raw_blocklist = request.form.get("keyword_blocklist", "") values = { "registration_open": request.form.get("registration_open") == "on", "ads_enabled": request.form.get("ads_enabled") == "on", "maintenance_mode": request.form.get("maintenance_mode") == "on", "flag_threshold": request.form.get("flag_threshold", type=int) or 5, "new_user_trust_gate_days": request.form.get("new_user_trust_gate_days", type=int) or 0, "contact_density_threshold": request.form.get("contact_density_threshold", type=int) or 3, "keyword_blocklist": [l.strip() for l in raw_blocklist.splitlines() if l.strip()], } for key, value in values.items(): set_setting(key, value) audit.log_action(current_user, "settings.updated", "setting", None, meta=values) db.session.commit() flash(_("Settings updated."), "success") return redirect(url_for("admin.settings")) values = { "registration_open": get_setting("registration_open", True), "ads_enabled": get_setting("ads_enabled", True), "maintenance_mode": get_setting("maintenance_mode", False), "flag_threshold": get_setting("flag_threshold", 5), "new_user_trust_gate_days": get_setting("new_user_trust_gate_days", 0), "contact_density_threshold": get_setting("contact_density_threshold", 3), "keyword_blocklist": get_setting("keyword_blocklist", []), } return render_template("admin/settings.html", values=values) # --------------------------------------------------------------------------- # Audit log # --------------------------------------------------------------------------- @admin_bp.route("/admin/audit") @admin_required def audit_log(): page = request.args.get("page", 1, type=int) action_filter = request.args.get("action", type=str) or None actor_filter = request.args.get("actor", type=int) or None q = AuditLog.query.order_by(AuditLog.created_at.desc()) if action_filter: q = q.filter(AuditLog.action.like(f"%{action_filter}%")) if actor_filter: q = q.filter(AuditLog.actor_id == actor_filter) pagination = q.paginate(page=page, per_page=PER_PAGE, error_out=False) return render_template("admin/audit.html", pagination=pagination, filters=request.args) # --------------------------------------------------------------------------- # Transactions log # --------------------------------------------------------------------------- @admin_bp.route("/admin/transactions") @admin_required def transactions(): page = request.args.get("page", 1, type=int) txn_type = request.args.get("type", type=str) or None txn_status = request.args.get("status", type=str) or None q = Transaction.query.order_by(Transaction.created_at.desc()) if txn_type: q = q.filter(Transaction.type == txn_type) if txn_status: q = q.filter(Transaction.status == txn_status) pagination = q.paginate(page=page, per_page=PER_PAGE, error_out=False) total_cents = db.session.query( db.func.sum(Transaction.amount_cents) ).filter(Transaction.status == "succeeded").scalar() or 0 return render_template("admin/transactions.html", pagination=pagination, filters=request.args, total_cents=total_cents) # --------------------------------------------------------------------------- # Category management # --------------------------------------------------------------------------- @admin_bp.route("/admin/categories") @admin_required def categories(): top_level = (Category.query.filter_by(parent_id=None) .order_by(Category.sort_order, Category.name).all()) return render_template("admin/categories.html", categories=top_level) @admin_bp.route("/admin/categories//toggle", methods=["POST"]) @admin_required def toggle_category(cat_id): cat = db.get_or_404(Category, cat_id) cat.is_active = not cat.is_active audit.log_action(current_user, "category.toggled", "category", cat.id, meta={"is_active": cat.is_active}) db.session.commit() flash(_("Category %(name)s %(state)s.", name=cat.name, state=_("enabled") if cat.is_active else _("disabled")), "success") return redirect(url_for("admin.categories")) @admin_bp.route("/admin/categories//schema", methods=["GET", "POST"]) @admin_required def category_schema(cat_id): cat = db.get_or_404(Category, cat_id) error = None if request.method == "POST": raw = request.form.get("field_schema", "") try: parsed = json.loads(raw) if not isinstance(parsed, dict): raise ValueError("must be a JSON object") cat.field_schema = parsed audit.log_action(current_user, "category.schema_updated", "category", cat.id) db.session.commit() flash(_("Field schema updated."), "success") return redirect(url_for("admin.categories")) except (json.JSONDecodeError, ValueError) as exc: error = str(exc) schema_str = json.dumps(cat.field_schema or {}, indent=2) return render_template("admin/category_schema.html", cat=cat, schema_str=schema_str, error=error) # --------------------------------------------------------------------------- # Plan management # --------------------------------------------------------------------------- @admin_bp.route("/admin/plans") @admin_required def plans(): all_plans = Plan.query.order_by(Plan.sort_order).all() return render_template("admin/plans.html", plans=all_plans) @admin_bp.route("/admin/plans/", methods=["GET", "POST"]) @admin_required def plan_edit(plan_id): plan = db.get_or_404(Plan, plan_id) error = None if request.method == "POST": name = request.form.get("name", "").strip() price_str = request.form.get("price_monthly_cents", "0") stripe_price_id = request.form.get("stripe_price_id", "").strip() or None config_raw = request.form.get("config", "") try: price_cents = int(price_str) config_parsed = json.loads(config_raw) if not isinstance(config_parsed, dict): raise ValueError("config must be a JSON object") plan.name = name plan.price_monthly_cents = price_cents plan.stripe_price_id = stripe_price_id plan.config = config_parsed audit.log_action(current_user, "plan.updated", "plan", plan.id, meta={"name": name, "price_cents": price_cents}) db.session.commit() flash(_("Plan updated."), "success") return redirect(url_for("admin.plans")) except (ValueError, json.JSONDecodeError) as exc: error = str(exc) config_str = json.dumps(plan.config or {}, indent=2) return render_template("admin/plan_edit.html", plan=plan, config_str=config_str, error=error)