757 lines
30 KiB
Python
757 lines
30 KiB
Python
"""Admin backend: dashboard KPIs + user management. Admin-only."""
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
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"),
|
|
datetime.utcnow()),
|
|
ends_at=_parse_dt(request.form.get("ends_at"),
|
|
datetime.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"), datetime.utcnow()),
|
|
ends_at=_parse_dt(request.form.get("ends_at"),
|
|
datetime.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"),
|
|
datetime.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 = datetime.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"))
|