06/16 Phase 6 (continue)

This commit is contained in:
2026-06-17 17:51:03 -04:00
parent 68a842d6b6
commit d1383a9835
11 changed files with 846 additions and 5 deletions
+352
View File
@@ -1,5 +1,6 @@
"""Admin backend: dashboard KPIs + user management. Admin-only.""" """Admin backend: dashboard KPIs + user management. Admin-only."""
import json import json
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
from flask_login import current_user from flask_login import current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -13,6 +14,7 @@ from app.models.trust import TrustEvent
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.report import Report from app.models.report import Report
from app.models.payments import Transaction from app.models.payments import Transaction
from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.enums import UserStatus, TrustEventType, ListingStatus from app.models.enums import UserStatus, TrustEventType, ListingStatus
from app.utils import admin_required from app.utils import admin_required
from app.services import admin_users as usvc from app.services import admin_users as usvc
@@ -402,3 +404,353 @@ def plan_edit(plan_id):
config_str = json.dumps(plan.config or {}, indent=2) config_str = json.dumps(plan.config or {}, indent=2)
return render_template("admin/plan_edit.html", plan=plan, return render_template("admin/plan_edit.html", plan=plan,
config_str=config_str, error=error) 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"))
+4
View File
@@ -5,7 +5,11 @@
<a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a> <a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a>
<a href="{{ url_for('admin.categories') }}" class="{{ 'on' if request.endpoint in ('admin.categories', 'admin.toggle_category', 'admin.category_schema') else '' }}">{{ _('Categories') }}</a> <a href="{{ url_for('admin.categories') }}" class="{{ 'on' if request.endpoint in ('admin.categories', 'admin.toggle_category', 'admin.category_schema') else '' }}">{{ _('Categories') }}</a>
<a href="{{ url_for('admin.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a> <a href="{{ url_for('admin.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a>
<a href="{{ url_for('admin.admin_ads') }}" class="{{ 'on' if request.endpoint in ('admin.admin_ads', 'admin.admin_ad_new', 'admin.admin_ad_edit') else '' }}">{{ _('Ads') }}</a>
<a href="{{ url_for('admin.admin_sponsors') }}" class="{{ 'on' if request.endpoint in ('admin.admin_sponsors', 'admin.admin_sponsor_new', 'admin.admin_sponsor_edit') else '' }}">{{ _('Sponsors') }}</a>
<a href="{{ url_for('admin.admin_promoted_keywords') }}" class="{{ 'on' if request.endpoint in ('admin.admin_promoted_keywords', 'admin.admin_promoted_keyword_new') else '' }}">{{ _('Keywords') }}</a>
<a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</a> <a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</a>
<a href="{{ url_for('admin.analytics') }}" class="{{ 'on' if request.endpoint == 'admin.analytics' else '' }}">{{ _('Analytics') }}</a>
<a href="{{ url_for('admin.audit_log') }}" class="{{ 'on' if request.endpoint == 'admin.audit_log' else '' }}">{{ _('Audit log') }}</a> <a href="{{ url_for('admin.audit_log') }}" class="{{ 'on' if request.endpoint == 'admin.audit_log' else '' }}">{{ _('Audit log') }}</a>
<a href="{{ url_for('admin.settings') }}" class="{{ 'on' if request.endpoint == 'admin.settings' else '' }}">{{ _('Settings') }}</a> <a href="{{ url_for('admin.settings') }}" class="{{ 'on' if request.endpoint == 'admin.settings' else '' }}">{{ _('Settings') }}</a>
</nav> </nav>
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · %(t)s', t=_('Edit ad') if ad else _('New ad')) }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Edit ad') if ad else _('New ad') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Advertiser name') }} *</label>
<input class="input" name="advertiser_name" required
value="{{ ad.advertiser_name if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Slot') }} *</label>
<select class="input" name="slot" required>
{% for s in ['header', 'sidebar', 'inline', 'footer'] %}
<option value="{{ s }}" {{ 'selected' if ad and ad.slot == s }}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Target URL') }} *</label>
<input class="input" name="target_url" required
value="{{ ad.target_url if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Creative path (relative under media/ads/)') }}</label>
<input class="input" name="creative_path"
value="{{ ad.creative_path or '' if ad else '' }}"
placeholder="{{ _('e.g. banner.jpg — leave blank for text-only ad') }}">
</div>
<div class="field">
<label>{{ _('Alt text') }}</label>
<input class="input" name="alt_text" value="{{ ad.alt_text or '' if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Language targeting (blank = all)') }}</label>
<select class="input" name="lang">
<option value="">{{ _('All') }}</option>
{% for l in ['en', 'vi', 'es'] %}
<option value="{{ l }}" {{ 'selected' if ad and ad.lang == l }}>{{ l }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('State targeting (2-letter, blank = all)') }}</label>
<input class="input" name="geo_state" maxlength="2"
value="{{ ad.geo_state or '' if ad else '' }}" placeholder="CA">
</div>
<div class="field">
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
<input class="input" name="starts_at" type="date"
value="{{ ad.starts_at.strftime('%Y-%m-%d') if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
<input class="input" name="ends_at" type="date"
value="{{ ad.ends_at.strftime('%Y-%m-%d') if ad else '' }}">
</div>
<div class="check">
<input type="checkbox" name="is_active" id="is_active"
{{ 'checked' if (not ad or ad.is_active) }}>
<label for="is_active">{{ _('Active') }}</label>
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_ads') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Ads') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Ads') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_ad_new') }}">{{ _('+ New ad') }}</a>
</div>
{% if not ad_list %}<p class="muted">{{ _('No ads yet.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Advertiser') }}</th>
<th>{{ _('Slot') }}</th>
<th>{{ _('Targeting') }}</th>
<th>{{ _('Schedule') }}</th>
<th>{{ _('Imp.') }}</th>
<th>{{ _('Clicks') }}</th>
<th>{{ _('CTR') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for ad in ad_list %}
<tr>
<td><a href="{{ url_for('admin.admin_ad_edit', ad_id=ad.id) }}">{{ ad.advertiser_name }}</a></td>
<td><span class="badge cat">{{ ad.slot }}</span></td>
<td class="muted small">
{% if ad.lang %}lang={{ ad.lang }}{% endif %}
{% if ad.geo_state %} state={{ ad.geo_state }}{% endif %}
{% if not ad.lang and not ad.geo_state %}—{% endif %}
</td>
<td class="muted small">
{{ ad.starts_at.strftime('%Y-%m-%d') }}
{{ ad.ends_at.strftime('%Y-%m-%d') }}
</td>
<td>{{ ad.impressions }}</td>
<td>{{ ad.clicks }}</td>
<td>{{ ad.ctr }}%</td>
<td>
{% if ad.is_running %}
<span class="badge ok">{{ _('Live') }}</span>
{% elif ad.is_active %}
<span class="badge cat">{{ _('Scheduled') }}</span>
{% else %}
<span class="badge warn">{{ _('Off') }}</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.admin_ad_toggle', ad_id=ad.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Toggle') }}</button>
</form>
<form method="post" action="{{ url_for('admin.admin_ad_delete', ad_id=ad.id) }}" style="display:inline"
onsubmit="return confirm('{{ _('Delete this ad?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+108
View File
@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Analytics') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Analytics — last 30 days') }}</h2>
<div class="kpi-grid">
<div class="card kpi-card">
<div class="kpi-label">{{ _('New signups (30d)') }}</div>
<div class="kpi-value">{{ signups|sum(attribute='n') }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Listings posted (30d)') }}</div>
<div class="kpi-value">{{ listings_chart|sum(attribute='n') }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Revenue (30d)') }}</div>
<div class="kpi-value">${{ '%.2f'|format((revenue_chart|sum(attribute='cents') or 0) / 100) }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Ad impressions (active ads)') }}</div>
<div class="kpi-value">{{ ad_stats.total_impressions or 0 }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Ad clicks (active ads)') }}</div>
<div class="kpi-value">{{ ad_stats.total_clicks or 0 }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Overall CTR') }}</div>
<div class="kpi-value">
{% if ad_stats.total_impressions %}
{{ '%.2f'|format(ad_stats.total_clicks / ad_stats.total_impressions * 100) }}%
{% else %}—{% endif %}
</div>
</div>
</div>
</div>
<div class="card">
<h3>{{ _('Signups per day') }}</h3>
{% if signups %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Signups') }}</th></tr></thead>
{% for row in signups %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>{{ row.n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No signups in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Listings posted per day') }}</h3>
{% if listings_chart %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Listings') }}</th></tr></thead>
{% for row in listings_chart %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>{{ row.n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No listings posted in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Revenue per day') }}</h3>
{% if revenue_chart %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Revenue') }}</th></tr></thead>
{% for row in revenue_chart %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>${{ '%.2f'|format((row.cents or 0) / 100) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No revenue in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Top categories by active listings') }}</h3>
{% if top_cats %}
<table class="list">
<thead><tr><th>{{ _('Category') }}</th><th>{{ _('Active listings') }}</th></tr></thead>
{% for name, n in top_cats %}
<tr>
<td>{{ name }}</td>
<td>{{ n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No listings.') }}</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Assign keyword') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Assign promoted keyword') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<p class="muted">
{{ _('The listing will appear at the top of search results when the keyword is matched (accent-insensitive).') }}
</p>
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Listing ID') }} *</label>
<input class="input" name="listing_id" type="number" required min="1"
placeholder="{{ _('Enter the numeric listing ID') }}">
</div>
<div class="field">
<label>{{ _('Keyword') }} *</label>
<input class="input" name="keyword" required maxlength="80"
placeholder="{{ _('e.g. pho, cleaning service') }}">
</div>
<div class="field">
<label>{{ _('Priority (higher = shown first)') }}</label>
<input class="input" name="priority" type="number" value="0">
</div>
<div class="field">
<label>{{ _('Expires at (YYYY-MM-DD)') }}</label>
<input class="input" name="expires_at" type="date">
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_promoted_keywords') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Promoted Keywords') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Promoted keywords') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_promoted_keyword_new') }}">{{ _('+ Assign keyword') }}</a>
</div>
{% if not pks %}<p class="muted">{{ _('No promoted keywords.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Keyword') }}</th>
<th>{{ _('Listing') }}</th>
<th>{{ _('Priority') }}</th>
<th>{{ _('Expires') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for pk in pks %}
<tr>
<td><code>{{ pk.keyword }}</code></td>
<td>
{% if pk.listing %}
<a href="{{ url_for('listings.detail', listing_id=pk.listing_id) }}">
{{ pk.listing.title[:60] }}
</a>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td>{{ pk.priority }}</td>
<td class="muted small">{{ pk.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if pk.is_active %}
<span class="badge ok">{{ _('Active') }}</span>
{% else %}
<span class="badge warn">{{ _('Expired') }}</span>
{% endif %}
</td>
<td>
<form method="post"
action="{{ url_for('admin.admin_promoted_keyword_delete', pk_id=pk.id) }}"
style="display:inline" onsubmit="return confirm('{{ _('Remove?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Remove') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · %(t)s', t=_('Edit sponsor') if sponsor else _('New sponsor')) }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Edit sponsor') if sponsor else _('New sponsor') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Name') }} *</label>
<input class="input" name="name" required
value="{{ sponsor.name if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('URL') }} *</label>
<input class="input" name="url" required
value="{{ sponsor.url if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Tagline') }}</label>
<input class="input" name="tagline"
value="{{ sponsor.tagline or '' if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Logo path (relative under media/)') }}</label>
<input class="input" name="logo_path"
value="{{ sponsor.logo_path or '' if sponsor else '' }}"
placeholder="{{ _('e.g. sponsors/logo.png') }}">
</div>
<div class="field">
<label>{{ _('Tier') }}</label>
<select class="input" name="tier">
{% for t in ['directory', 'category'] %}
<option value="{{ t }}" {{ 'selected' if sponsor and sponsor.tier == t }}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Category (for category-tier sponsors)') }}</label>
<select class="input" name="category_id">
<option value="">{{ _('None') }}</option>
{% for cat in categories %}
<option value="{{ cat.id }}"
{{ 'selected' if sponsor and sponsor.category_id == cat.id }}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
<input class="input" name="starts_at" type="date"
value="{{ sponsor.starts_at.strftime('%Y-%m-%d') if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
<input class="input" name="ends_at" type="date"
value="{{ sponsor.ends_at.strftime('%Y-%m-%d') if sponsor else '' }}">
</div>
<div class="check">
<input type="checkbox" name="is_active" id="is_active"
{{ 'checked' if (not sponsor or sponsor.is_active) }}>
<label for="is_active">{{ _('Active') }}</label>
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_sponsors') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Sponsors') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Sponsors') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_sponsor_new') }}">{{ _('+ New sponsor') }}</a>
</div>
{% if not sponsors %}<p class="muted">{{ _('No sponsors yet.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Tier') }}</th>
<th>{{ _('Category') }}</th>
<th>{{ _('Schedule') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for sp in sponsors %}
<tr>
<td><a href="{{ url_for('admin.admin_sponsor_edit', sponsor_id=sp.id) }}">{{ sp.name }}</a></td>
<td><span class="badge cat">{{ sp.tier }}</span></td>
<td class="muted small">{{ sp.category.name if sp.category else '—' }}</td>
<td class="muted small">
{{ sp.starts_at.strftime('%Y-%m-%d') }}
{{ sp.ends_at.strftime('%Y-%m-%d') }}
</td>
<td>
{% if sp.is_running %}
<span class="badge ok">{{ _('Live') }}</span>
{% elif sp.is_active %}
<span class="badge cat">{{ _('Scheduled') }}</span>
{% else %}
<span class="badge warn">{{ _('Off') }}</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.admin_sponsor_delete', sponsor_id=sp.id) }}"
style="display:inline" onsubmit="return confirm('{{ _('Delete?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+11
View File
@@ -37,6 +37,7 @@
<th>{{ _('Amount') }}</th> <th>{{ _('Amount') }}</th>
<th>{{ _('Status') }}</th> <th>{{ _('Status') }}</th>
<th>{{ _('Stripe ID') }}</th> <th>{{ _('Stripe ID') }}</th>
<th></th>
</tr></thead> </tr></thead>
{% for txn in pagination.items %} {% for txn in pagination.items %}
<tr> <tr>
@@ -46,6 +47,16 @@
<td>${{ '%.2f'|format(txn.amount_cents / 100) }}</td> <td>${{ '%.2f'|format(txn.amount_cents / 100) }}</td>
<td><span class="badge {{ 'ok' if txn.status == 'succeeded' else 'warn' }}">{{ txn.status }}</span></td> <td><span class="badge {{ 'ok' if txn.status == 'succeeded' else 'warn' }}">{{ txn.status }}</span></td>
<td class="muted small">{{ txn.stripe_object_id or '—' }}</td> <td class="muted small">{{ txn.stripe_object_id or '—' }}</td>
<td>
{% if txn.status == 'succeeded' and txn.type != 'refund' %}
<form method="post"
action="{{ url_for('admin.refund_transaction', txn_id=txn.id) }}"
onsubmit="return confirm('{{ _('Issue refund for $%(a)s?', a='%.2f'|format(txn.amount_cents/100)) }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Refund') }}</button>
</form>
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr> <tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr>
+17 -5
View File
@@ -10,7 +10,7 @@ import os
import re import re
import io import io
import logging import logging
import shutil
import tempfile import tempfile
os.environ.setdefault("SECRET_KEY", "test-secret") os.environ.setdefault("SECRET_KEY", "test-secret")
@@ -20,6 +20,11 @@ _SMOKE_DB = os.path.join(tempfile.gettempdir(), "classifieds_smoke.db")
if os.path.exists(_SMOKE_DB): if os.path.exists(_SMOKE_DB):
os.remove(_SMOKE_DB) os.remove(_SMOKE_DB)
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}") os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}")
# Use a dedicated temp media dir so image counts are deterministic across runs.
_SMOKE_MEDIA = os.path.join(tempfile.gettempdir(), "classifieds_smoke_media")
if os.path.exists(_SMOKE_MEDIA):
shutil.rmtree(_SMOKE_MEDIA)
os.environ["MEDIA_ROOT"] = _SMOKE_MEDIA
os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory
os.environ.setdefault("FLASK_CONFIG", "dev") os.environ.setdefault("FLASK_CONFIG", "dev")
@@ -273,7 +278,7 @@ def _phase4(app):
user_fresh = db.session.get(User, user.id) user_fresh = db.session.get(User, user.id)
assert user_fresh.role == Role.subscriber assert user_fresh.role == Role.subscriber
assert user_fresh.tier.slug == "pro" assert user_fresh.tier.slug == "pro"
print("sync_subscription user upgraded to pro subscriber: ok") print("sync_subscription -> user upgraded to pro subscriber: ok")
# --- downgrade to free --- # --- downgrade to free ---
bsvc.downgrade_to_free(user.id) bsvc.downgrade_to_free(user.id)
@@ -659,7 +664,8 @@ def _phase6(app):
login("t@example.com", "NewPass456") login("t@example.com", "NewPass456")
for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings", for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings",
"/admin/categories", "/admin/plans", "/admin/transactions", "/admin/categories", "/admin/plans", "/admin/transactions",
"/admin/audit"): "/admin/audit", "/admin/ads", "/admin/sponsors",
"/admin/promoted-keywords", "/admin/analytics"):
assert c.get(path, base_url=B).status_code == 403 assert c.get(path, base_url=B).status_code == 403
print("non-admin /admin* -> 403: ok") print("non-admin /admin* -> 403: ok")
c.get("/auth/logout", base_url=B) c.get("/auth/logout", base_url=B)
@@ -674,7 +680,11 @@ def _phase6(app):
"/admin/listings", "/admin/reports", "/admin/settings", "/admin/listings", "/admin/reports", "/admin/settings",
"/admin/categories", f"/admin/categories/{cat_id}/schema", "/admin/categories", f"/admin/categories/{cat_id}/schema",
"/admin/plans", f"/admin/plans/{plan_id}", "/admin/plans", f"/admin/plans/{plan_id}",
"/admin/transactions", "/admin/audit"): "/admin/transactions", "/admin/audit",
"/admin/ads", "/admin/ads/new",
"/admin/sponsors", "/admin/sponsors/new",
"/admin/promoted-keywords", "/admin/promoted-keywords/new",
"/admin/analytics"):
code = c.get(path, base_url=B).status_code code = c.get(path, base_url=B).status_code
assert code == 200, f"{path} -> {code}" assert code == 200, f"{path} -> {code}"
print(f"{code} {path}") print(f"{code} {path}")
@@ -993,7 +1003,9 @@ def _phase2(app):
db.session.add(img); db.session.commit() db.session.add(img); db.session.commit()
assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600 assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600
import os import os
media = os.path.join(app.instance_path, "media", str(l1.id)) media_root = (app.config.get("MEDIA_ROOT")
or os.path.join(app.instance_path, "media"))
media = os.path.join(media_root, str(l1.id))
assert os.path.isdir(media) and len(os.listdir(media)) == 2 assert os.path.isdir(media) and len(os.listdir(media)) == 2
print("image pipeline (re-encode + thumbnail + EXIF strip): ok") print("image pipeline (re-encode + thumbnail + EXIF strip): ok")