Files
classifieds/app/blueprints/admin/routes.py
T
2026-07-13 16:52:00 -04:00

758 lines
30 KiB
Python

"""Admin backend: dashboard KPIs + user management. Admin-only."""
import json
from datetime import datetime, timedelta
from app.utils.time import utcnow
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.ads import Ad, Sponsor, PromotedKeyword
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)
# ---------------------------------------------------------------------------
# Ads management
# ---------------------------------------------------------------------------
def _parse_dt(s, fallback=None):
"""Parse 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM' strings into datetime."""
if not s:
return fallback
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s.strip(), fmt)
except ValueError:
continue
return fallback
@admin_bp.route("/admin/ads")
@admin_required
def admin_ads():
ad_list = Ad.query.order_by(Ad.created_at.desc()).all()
return render_template("admin/ads.html", ad_list=ad_list)
@admin_bp.route("/admin/ads/new", methods=["GET", "POST"])
@admin_required
def admin_ad_new():
error = None
if request.method == "POST":
try:
ad = Ad(
advertiser_name=request.form["advertiser_name"].strip(),
slot=request.form["slot"],
target_url=request.form["target_url"].strip(),
alt_text=request.form.get("alt_text", "").strip() or None,
creative_path=request.form.get("creative_path", "").strip() or None,
lang=request.form.get("lang", "").strip() or None,
geo_state=request.form.get("geo_state", "").strip().upper() or None,
starts_at=_parse_dt(request.form.get("starts_at"),
utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(ad)
audit.log_action(current_user, "ad.created", "ad", None,
meta={"advertiser": ad.advertiser_name,
"slot": ad.slot})
db.session.commit()
flash(_("Ad created."), "success")
return redirect(url_for("admin.admin_ads"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/ad_edit.html", ad=None, error=error)
@admin_bp.route("/admin/ads/<int:ad_id>", methods=["GET", "POST"])
@admin_required
def admin_ad_edit(ad_id):
ad = db.get_or_404(Ad, ad_id)
error = None
if request.method == "POST":
try:
ad.advertiser_name = request.form["advertiser_name"].strip()
ad.slot = request.form["slot"]
ad.target_url = request.form["target_url"].strip()
ad.alt_text = request.form.get("alt_text", "").strip() or None
ad.creative_path = request.form.get("creative_path", "").strip() or None
ad.lang = request.form.get("lang", "").strip() or None
ad.geo_state = request.form.get("geo_state", "").strip().upper() or None
ad.starts_at = _parse_dt(request.form.get("starts_at"), ad.starts_at)
ad.ends_at = _parse_dt(request.form.get("ends_at"), ad.ends_at)
ad.is_active = request.form.get("is_active") == "on"
audit.log_action(current_user, "ad.updated", "ad", ad.id,
meta={"advertiser": ad.advertiser_name})
db.session.commit()
flash(_("Ad updated."), "success")
return redirect(url_for("admin.admin_ads"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/ad_edit.html", ad=ad, error=error)
@admin_bp.route("/admin/ads/<int:ad_id>/delete", methods=["POST"])
@admin_required
def admin_ad_delete(ad_id):
ad = db.get_or_404(Ad, ad_id)
audit.log_action(current_user, "ad.deleted", "ad", ad.id,
meta={"advertiser": ad.advertiser_name})
db.session.delete(ad)
db.session.commit()
flash(_("Ad deleted."), "info")
return redirect(url_for("admin.admin_ads"))
@admin_bp.route("/admin/ads/<int:ad_id>/toggle", methods=["POST"])
@admin_required
def admin_ad_toggle(ad_id):
ad = db.get_or_404(Ad, ad_id)
ad.is_active = not ad.is_active
audit.log_action(current_user, "ad.toggled", "ad", ad.id,
meta={"is_active": ad.is_active})
db.session.commit()
flash(_("Ad %(state)s.", state=_("enabled") if ad.is_active else _("disabled")),
"success")
return redirect(url_for("admin.admin_ads"))
# ---------------------------------------------------------------------------
# Sponsors management
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/sponsors")
@admin_required
def admin_sponsors():
sponsors = Sponsor.query.order_by(Sponsor.created_at.desc()).all()
return render_template("admin/sponsors.html", sponsors=sponsors)
@admin_bp.route("/admin/sponsors/new", methods=["GET", "POST"])
@admin_required
def admin_sponsor_new():
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
Category.sort_order, Category.name).all()
error = None
if request.method == "POST":
try:
cat_id = request.form.get("category_id", type=int) or None
sp = Sponsor(
name=request.form["name"].strip(),
url=request.form["url"].strip(),
tagline=request.form.get("tagline", "").strip() or None,
logo_path=request.form.get("logo_path", "").strip() or None,
tier=request.form.get("tier", "directory"),
category_id=cat_id,
starts_at=_parse_dt(request.form.get("starts_at"), utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(sp)
audit.log_action(current_user, "sponsor.created", "sponsor", None,
meta={"name": sp.name})
db.session.commit()
flash(_("Sponsor created."), "success")
return redirect(url_for("admin.admin_sponsors"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/sponsor_edit.html", sponsor=None,
categories=cats, error=error)
@admin_bp.route("/admin/sponsors/<int:sponsor_id>", methods=["GET", "POST"])
@admin_required
def admin_sponsor_edit(sponsor_id):
sp = db.get_or_404(Sponsor, sponsor_id)
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
Category.sort_order, Category.name).all()
error = None
if request.method == "POST":
try:
sp.name = request.form["name"].strip()
sp.url = request.form["url"].strip()
sp.tagline = request.form.get("tagline", "").strip() or None
sp.logo_path = request.form.get("logo_path", "").strip() or None
sp.tier = request.form.get("tier", "directory")
sp.category_id = request.form.get("category_id", type=int) or None
sp.starts_at = _parse_dt(request.form.get("starts_at"), sp.starts_at)
sp.ends_at = _parse_dt(request.form.get("ends_at"), sp.ends_at)
sp.is_active = request.form.get("is_active") == "on"
audit.log_action(current_user, "sponsor.updated", "sponsor", sp.id,
meta={"name": sp.name})
db.session.commit()
flash(_("Sponsor updated."), "success")
return redirect(url_for("admin.admin_sponsors"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/sponsor_edit.html", sponsor=sp,
categories=cats, error=error)
@admin_bp.route("/admin/sponsors/<int:sponsor_id>/delete", methods=["POST"])
@admin_required
def admin_sponsor_delete(sponsor_id):
sp = db.get_or_404(Sponsor, sponsor_id)
audit.log_action(current_user, "sponsor.deleted", "sponsor", sp.id,
meta={"name": sp.name})
db.session.delete(sp)
db.session.commit()
flash(_("Sponsor deleted."), "info")
return redirect(url_for("admin.admin_sponsors"))
# ---------------------------------------------------------------------------
# Promoted keywords
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/promoted-keywords")
@admin_required
def admin_promoted_keywords():
pks = (PromotedKeyword.query
.order_by(PromotedKeyword.expires_at.desc()).all())
return render_template("admin/promoted_keywords.html", pks=pks)
@admin_bp.route("/admin/promoted-keywords/new", methods=["GET", "POST"])
@admin_required
def admin_promoted_keyword_new():
error = None
if request.method == "POST":
listing_id = request.form.get("listing_id", type=int)
keyword = request.form.get("keyword", "").strip()
priority = request.form.get("priority", 0, type=int)
expires_at = _parse_dt(request.form.get("expires_at"),
utcnow() + timedelta(days=7))
listing = db.session.get(Listing, listing_id) if listing_id else None
if not listing:
error = "Listing not found."
elif not keyword:
error = "Keyword required."
else:
try:
pk = PromotedKeyword(keyword=keyword, listing_id=listing_id,
priority=priority, expires_at=expires_at)
db.session.add(pk)
audit.log_action(current_user, "promoted_keyword.created",
"promoted_keyword", None,
meta={"keyword": keyword, "listing_id": listing_id})
db.session.commit()
flash(_("Promoted keyword added."), "success")
return redirect(url_for("admin.admin_promoted_keywords"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/promoted_keyword_new.html", error=error)
@admin_bp.route("/admin/promoted-keywords/<int:pk_id>/delete", methods=["POST"])
@admin_required
def admin_promoted_keyword_delete(pk_id):
pk = db.get_or_404(PromotedKeyword, pk_id)
audit.log_action(current_user, "promoted_keyword.deleted",
"promoted_keyword", pk.id,
meta={"keyword": pk.keyword})
db.session.delete(pk)
db.session.commit()
flash(_("Promoted keyword removed."), "info")
return redirect(url_for("admin.admin_promoted_keywords"))
# ---------------------------------------------------------------------------
# Analytics
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/analytics")
@admin_required
def analytics():
since_30 = utcnow() - timedelta(days=30)
# signups per day (last 30 days)
signups_raw = (db.session.query(
db.func.date(User.created_at).label("day"),
db.func.count().label("n"))
.filter(User.created_at >= since_30)
.group_by(db.func.date(User.created_at))
.order_by(db.func.date(User.created_at))
.all())
# listings posted per day (last 30 days)
listings_raw = (db.session.query(
db.func.date(Listing.created_at).label("day"),
db.func.count().label("n"))
.filter(Listing.created_at >= since_30)
.group_by(db.func.date(Listing.created_at))
.order_by(db.func.date(Listing.created_at))
.all())
# revenue per day (last 30 days)
revenue_raw = (db.session.query(
db.func.date(Transaction.created_at).label("day"),
db.func.sum(Transaction.amount_cents).label("cents"))
.filter(Transaction.created_at >= since_30,
Transaction.status == "succeeded")
.group_by(db.func.date(Transaction.created_at))
.order_by(db.func.date(Transaction.created_at))
.all())
# top categories by listing count (active)
top_cats = (db.session.query(
Category.name,
db.func.count(Listing.id).label("n"))
.join(Listing, Listing.category_id == Category.id)
.filter(Listing.status == ListingStatus.active)
.group_by(Category.id, Category.name)
.order_by(db.func.count(Listing.id).desc())
.limit(10).all())
# ad performance summary
ad_stats = (db.session.query(
db.func.sum(Ad.impressions).label("total_impressions"),
db.func.sum(Ad.clicks).label("total_clicks"))
.filter(Ad.is_active == True)
.first())
return render_template("admin/analytics.html",
signups=signups_raw,
listings_chart=listings_raw,
revenue_chart=revenue_raw,
top_cats=top_cats,
ad_stats=ad_stats)
# ---------------------------------------------------------------------------
# Refund action
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/transactions/<int:txn_id>/refund", methods=["POST"])
@admin_required
def refund_transaction(txn_id):
txn = db.get_or_404(Transaction, txn_id)
if txn.status != "succeeded":
flash(_("Only succeeded transactions can be refunded."), "danger")
return redirect(url_for("admin.transactions"))
from app.services.billing import stripe_enabled
if stripe_enabled():
import stripe as stripe_lib
try:
stripe_lib.Refund.create(payment_intent=txn.stripe_object_id)
except stripe_lib.error.StripeError as exc:
flash(_("Stripe refund failed: %(m)s", m=str(exc)), "danger")
return redirect(url_for("admin.transactions"))
# record local refund transaction
refund_txn = Transaction(
user_id=txn.user_id,
type="refund",
amount_cents=-abs(txn.amount_cents),
currency=txn.currency or "usd",
stripe_object_id=txn.stripe_object_id,
status="succeeded",
meta={"refunded_txn_id": txn.id},
)
db.session.add(refund_txn)
audit.log_action(current_user, "transaction.refunded", "transaction", txn.id,
meta={"amount_cents": txn.amount_cents,
"user_id": txn.user_id})
db.session.commit()
flash(_("Refund recorded."), "success")
return redirect(url_for("admin.transactions"))