06/16 Phase 6 (continue)
This commit is contained in:
+217
-37
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.extensions import db
|
||||
from app.models.ads import Ad
|
||||
from app.services.ads import (get_ad, record_impression, record_click,
|
||||
active_sponsors)
|
||||
from app.services.settings import get_setting
|
||||
|
||||
ads_bp = Blueprint("ads", __name__)
|
||||
|
||||
@@ -43,6 +44,9 @@ def inject_ads():
|
||||
Returns ad slots for use in templates.
|
||||
Ads suppressed for subscribers (ad_free plan limit).
|
||||
"""
|
||||
if not get_setting("ads_enabled", True):
|
||||
return {"ads": {}, "show_ads": False}
|
||||
|
||||
show_ads = True
|
||||
if current_user.is_authenticated:
|
||||
plan = current_user.tier
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.utils.security import generate_token, read_token
|
||||
from app.services.email import send_email
|
||||
from app.services.turnstile import verify_turnstile, turnstile_enabled
|
||||
from app.services.trust import record_event
|
||||
from app.services.settings import get_setting
|
||||
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
|
||||
ResetRequestForm, ResetForm)
|
||||
|
||||
@@ -36,6 +37,10 @@ def register():
|
||||
return redirect(url_for("main.index"))
|
||||
form = RegisterForm()
|
||||
if form.validate_on_submit():
|
||||
if not get_setting("registration_open", True):
|
||||
flash(_("Registration is currently closed."), "danger")
|
||||
return render_template("auth/register.html", form=form,
|
||||
turnstile=turnstile_enabled())
|
||||
if not verify_turnstile():
|
||||
flash(_("Captcha verification failed."), "danger")
|
||||
return render_template("auth/register.html", form=form,
|
||||
|
||||
Reference in New Issue
Block a user