"""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/") @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) # --------------------------------------------------------------------------- # 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/", 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//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//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/", 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//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//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//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"))