Files
classifieds/app/blueprints/admin/routes.py
T
2026-06-17 17:06:43 -04:00

405 lines
16 KiB
Python

"""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/<int:user_id>")
@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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:listing_id>/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/<int:listing_id>/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/<int:listing_id>/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/<int:report_id>/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/<int:report_id>/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/<int:cat_id>/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/<int:cat_id>/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/<int:plan_id>", 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)