06/16 Phase 6 (continue)

This commit is contained in:
2026-06-17 17:06:43 -04:00
parent 479afde2fa
commit 68a842d6b6
26 changed files with 1027 additions and 61 deletions
+217 -37
View File
@@ -1,5 +1,6 @@
"""Admin backend: dashboard KPIs + user management. Admin-only."""
from flask import Blueprint, render_template, request, redirect, url_for, flash
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 _
@@ -7,9 +8,11 @@ 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
@@ -23,6 +26,9 @@ admin_bp = Blueprint("admin", __name__)
PER_PAGE = 25
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
@admin_bp.route("/admin")
@admin_required
def dashboard():
@@ -37,6 +43,9 @@ def dashboard():
return render_template("admin/dashboard.html", kpis=kpis)
# ---------------------------------------------------------------------------
# User management
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/users")
@admin_required
def users():
@@ -53,7 +62,7 @@ def users():
@admin_bp.route("/admin/users/<int:user_id>")
@admin_required
def user_detail(user_id):
user = User.query.get_or_404(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())
@@ -76,7 +85,7 @@ def _guard_self_action(user):
@admin_bp.route("/admin/users/<int:user_id>/ban", methods=["POST"])
@admin_required
def ban_user(user_id):
user = User.query.get_or_404(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)
@@ -88,7 +97,7 @@ def ban_user(user_id):
@admin_bp.route("/admin/users/<int:user_id>/suspend", methods=["POST"])
@admin_required
def suspend_user(user_id):
user = User.query.get_or_404(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)
@@ -100,7 +109,7 @@ def suspend_user(user_id):
@admin_bp.route("/admin/users/<int:user_id>/activate", methods=["POST"])
@admin_required
def activate_user(user_id):
user = User.query.get_or_404(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")
@@ -110,7 +119,7 @@ def activate_user(user_id):
@admin_bp.route("/admin/users/<int:user_id>/tier", methods=["POST"])
@admin_required
def set_tier(user_id):
user = User.query.get_or_404(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)
@@ -122,7 +131,7 @@ def set_tier(user_id):
@admin_bp.route("/admin/users/<int:user_id>/trust", methods=["POST"])
@admin_required
def trust_adjust(user_id):
user = User.query.get_or_404(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)
@@ -131,7 +140,9 @@ def trust_adjust(user_id):
return redirect(url_for("admin.user_detail", user_id=user.id))
# --- listing moderation ---
# ---------------------------------------------------------------------------
# Listing moderation
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/listings")
@admin_required
def listings():
@@ -144,25 +155,10 @@ def listings():
keyword_blocklist=keyword_blocklist)
@admin_bp.route("/admin/listings/settings", methods=["POST"])
@admin_required
def listings_settings():
threshold = request.form.get("flag_threshold", type=int) or 5
raw_blocklist = request.form.get("keyword_blocklist", "")
blocklist = [line.strip() for line in raw_blocklist.splitlines() if line.strip()]
set_setting("flag_threshold", threshold)
set_setting("keyword_blocklist", blocklist)
audit.log_action(current_user, "settings.updated", "setting", None,
meta={"flag_threshold": threshold, "keyword_blocklist": blocklist})
db.session.commit()
flash(_("Moderation settings updated."), "success")
return redirect(url_for("admin.listings"))
@admin_bp.route("/admin/listings/<int:listing_id>/approve", methods=["POST"])
@admin_required
def approve_listing(listing_id):
listing = Listing.query.get_or_404(listing_id)
listing = db.get_or_404(Listing, listing_id)
msvc.approve(listing, actor=current_user)
db.session.commit()
flash(_("Listing approved."), "success")
@@ -172,7 +168,7 @@ def approve_listing(listing_id):
@admin_bp.route("/admin/listings/<int:listing_id>/hide", methods=["POST"])
@admin_required
def hide_listing(listing_id):
listing = Listing.query.get_or_404(listing_id)
listing = db.get_or_404(Listing, listing_id)
msvc.hide(listing, actor=current_user)
db.session.commit()
flash(_("Listing hidden."), "warning")
@@ -182,32 +178,45 @@ def hide_listing(listing_id):
@admin_bp.route("/admin/listings/<int:listing_id>/remove", methods=["POST"])
@admin_required
def remove_listing(listing_id):
listing = Listing.query.get_or_404(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 ---
# ---------------------------------------------------------------------------
# Reports queue
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/reports")
@admin_required
def reports():
candidates = (Report.query.join(Listing, Report.listing_id == Listing.id)
.filter(Listing.status == ListingStatus.flagged)
.order_by(Report.created_at.desc()).all())
dismissed_ids = {a.target_id for a in
AuditLog.query.filter_by(target_type="report", action="report.dismissed").all()}
open_reports = [r for r in candidates if r.id not in dismissed_ids]
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 = Report.query.get_or_404(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})
meta={"listing_id": report.listing_id,
"reporter_id": report.reporter_id})
db.session.commit()
flash(_("Report dismissed."), "info")
return redirect(url_for("admin.reports"))
@@ -216,9 +225,180 @@ def dismiss_report(report_id):
@admin_bp.route("/admin/reports/<int:report_id>/escalate", methods=["POST"])
@admin_required
def escalate_report(report_id):
report = Report.query.get_or_404(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})
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)