From d1383a9835bcd98dc320ec8d9fbc7bb1fc3689d7 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 17 Jun 2026 17:51:03 -0400 Subject: [PATCH] 06/16 Phase 6 (continue) --- app/blueprints/admin/routes.py | 352 ++++++++++++++++++ app/templates/admin/_nav.html | 4 + app/templates/admin/ad_edit.html | 76 ++++ app/templates/admin/ads.html | 64 ++++ app/templates/admin/analytics.html | 108 ++++++ app/templates/admin/promoted_keyword_new.html | 37 ++ app/templates/admin/promoted_keywords.html | 52 +++ app/templates/admin/sponsor_edit.html | 75 ++++ app/templates/admin/sponsors.html | 50 +++ app/templates/admin/transactions.html | 11 + tests/test_smoke.py | 22 +- 11 files changed, 846 insertions(+), 5 deletions(-) create mode 100644 app/templates/admin/ad_edit.html create mode 100644 app/templates/admin/ads.html create mode 100644 app/templates/admin/analytics.html create mode 100644 app/templates/admin/promoted_keyword_new.html create mode 100644 app/templates/admin/promoted_keywords.html create mode 100644 app/templates/admin/sponsor_edit.html create mode 100644 app/templates/admin/sponsors.html diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py index c0c1f57..3192734 100644 --- a/app/blueprints/admin/routes.py +++ b/app/blueprints/admin/routes.py @@ -1,5 +1,6 @@ """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 _ @@ -13,6 +14,7 @@ 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 @@ -402,3 +404,353 @@ def plan_edit(plan_id): 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/", 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"), 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/", 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"), + 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//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//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")) diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 2c5935b..54e2691 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -5,7 +5,11 @@ {{ _('Reports') }} {{ _('Categories') }} {{ _('Plans') }} + {{ _('Ads') }} + {{ _('Sponsors') }} + {{ _('Keywords') }} {{ _('Transactions') }} + {{ _('Analytics') }} {{ _('Audit log') }} {{ _('Settings') }} diff --git a/app/templates/admin/ad_edit.html b/app/templates/admin/ad_edit.html new file mode 100644 index 0000000..779044e --- /dev/null +++ b/app/templates/admin/ad_edit.html @@ -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" %} +
+

{{ _('Edit ad') if ad else _('New ad') }}

+ {% if error %}

{{ error }}

{% endif %} + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {{ _('Cancel') }} +
+
+
+{% endblock %} diff --git a/app/templates/admin/ads.html b/app/templates/admin/ads.html new file mode 100644 index 0000000..ffd1cbb --- /dev/null +++ b/app/templates/admin/ads.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Ads') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+
+

{{ _('Ads') }}

+ {{ _('+ New ad') }} +
+ + {% if not ad_list %}

{{ _('No ads yet.') }}

{% endif %} + + + + + + + + + + + + + {% for ad in ad_list %} + + + + + + + + + + + + {% endfor %} +
{{ _('Advertiser') }}{{ _('Slot') }}{{ _('Targeting') }}{{ _('Schedule') }}{{ _('Imp.') }}{{ _('Clicks') }}{{ _('CTR') }}{{ _('Status') }}
{{ ad.advertiser_name }}{{ ad.slot }} + {% 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 %} + + {{ ad.starts_at.strftime('%Y-%m-%d') }} – + {{ ad.ends_at.strftime('%Y-%m-%d') }} + {{ ad.impressions }}{{ ad.clicks }}{{ ad.ctr }}% + {% if ad.is_running %} + {{ _('Live') }} + {% elif ad.is_active %} + {{ _('Scheduled') }} + {% else %} + {{ _('Off') }} + {% endif %} + +
+ + +
+
+ + +
+
+
+{% endblock %} diff --git a/app/templates/admin/analytics.html b/app/templates/admin/analytics.html new file mode 100644 index 0000000..941e665 --- /dev/null +++ b/app/templates/admin/analytics.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Analytics') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} + +
+

{{ _('Analytics — last 30 days') }}

+ +
+
+
{{ _('New signups (30d)') }}
+
{{ signups|sum(attribute='n') }}
+
+
+
{{ _('Listings posted (30d)') }}
+
{{ listings_chart|sum(attribute='n') }}
+
+
+
{{ _('Revenue (30d)') }}
+
${{ '%.2f'|format((revenue_chart|sum(attribute='cents') or 0) / 100) }}
+
+
+
{{ _('Ad impressions (active ads)') }}
+
{{ ad_stats.total_impressions or 0 }}
+
+
+
{{ _('Ad clicks (active ads)') }}
+
{{ ad_stats.total_clicks or 0 }}
+
+
+
{{ _('Overall CTR') }}
+
+ {% if ad_stats.total_impressions %} + {{ '%.2f'|format(ad_stats.total_clicks / ad_stats.total_impressions * 100) }}% + {% else %}—{% endif %} +
+
+
+
+ +
+

{{ _('Signups per day') }}

+ {% if signups %} + + + {% for row in signups %} + + + + + {% endfor %} +
{{ _('Date') }}{{ _('Signups') }}
{{ row.day }}{{ row.n }}
+ {% else %} +

{{ _('No signups in the last 30 days.') }}

+ {% endif %} +
+ +
+

{{ _('Listings posted per day') }}

+ {% if listings_chart %} + + + {% for row in listings_chart %} + + + + + {% endfor %} +
{{ _('Date') }}{{ _('Listings') }}
{{ row.day }}{{ row.n }}
+ {% else %} +

{{ _('No listings posted in the last 30 days.') }}

+ {% endif %} +
+ +
+

{{ _('Revenue per day') }}

+ {% if revenue_chart %} + + + {% for row in revenue_chart %} + + + + + {% endfor %} +
{{ _('Date') }}{{ _('Revenue') }}
{{ row.day }}${{ '%.2f'|format((row.cents or 0) / 100) }}
+ {% else %} +

{{ _('No revenue in the last 30 days.') }}

+ {% endif %} +
+ +
+

{{ _('Top categories by active listings') }}

+ {% if top_cats %} + + + {% for name, n in top_cats %} + + + + + {% endfor %} +
{{ _('Category') }}{{ _('Active listings') }}
{{ name }}{{ n }}
+ {% else %} +

{{ _('No listings.') }}

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/admin/promoted_keyword_new.html b/app/templates/admin/promoted_keyword_new.html new file mode 100644 index 0000000..f471357 --- /dev/null +++ b/app/templates/admin/promoted_keyword_new.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Assign keyword') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Assign promoted keyword') }}

+ {% if error %}

{{ error }}

{% endif %} +

+ {{ _('The listing will appear at the top of search results when the keyword is matched (accent-insensitive).') }} +

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {{ _('Cancel') }} +
+
+
+{% endblock %} diff --git a/app/templates/admin/promoted_keywords.html b/app/templates/admin/promoted_keywords.html new file mode 100644 index 0000000..f60094c --- /dev/null +++ b/app/templates/admin/promoted_keywords.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Promoted Keywords') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+
+

{{ _('Promoted keywords') }}

+ {{ _('+ Assign keyword') }} +
+ + {% if not pks %}

{{ _('No promoted keywords.') }}

{% endif %} + + + + + + + + + + {% for pk in pks %} + + + + + + + + + {% endfor %} +
{{ _('Keyword') }}{{ _('Listing') }}{{ _('Priority') }}{{ _('Expires') }}{{ _('Status') }}
{{ pk.keyword }} + {% if pk.listing %} + + {{ pk.listing.title[:60] }} + + {% else %}{% endif %} + {{ pk.priority }}{{ pk.expires_at.strftime('%Y-%m-%d') }} + {% if pk.is_active %} + {{ _('Active') }} + {% else %} + {{ _('Expired') }} + {% endif %} + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/admin/sponsor_edit.html b/app/templates/admin/sponsor_edit.html new file mode 100644 index 0000000..4a678e7 --- /dev/null +++ b/app/templates/admin/sponsor_edit.html @@ -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" %} +
+

{{ _('Edit sponsor') if sponsor else _('New sponsor') }}

+ {% if error %}

{{ error }}

{% endif %} + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {{ _('Cancel') }} +
+
+
+{% endblock %} diff --git a/app/templates/admin/sponsors.html b/app/templates/admin/sponsors.html new file mode 100644 index 0000000..7abbf45 --- /dev/null +++ b/app/templates/admin/sponsors.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Sponsors') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+
+

{{ _('Sponsors') }}

+ {{ _('+ New sponsor') }} +
+ + {% if not sponsors %}

{{ _('No sponsors yet.') }}

{% endif %} + + + + + + + + + + {% for sp in sponsors %} + + + + + + + + + {% endfor %} +
{{ _('Name') }}{{ _('Tier') }}{{ _('Category') }}{{ _('Schedule') }}{{ _('Status') }}
{{ sp.name }}{{ sp.tier }}{{ sp.category.name if sp.category else '—' }} + {{ sp.starts_at.strftime('%Y-%m-%d') }} – + {{ sp.ends_at.strftime('%Y-%m-%d') }} + + {% if sp.is_running %} + {{ _('Live') }} + {% elif sp.is_active %} + {{ _('Scheduled') }} + {% else %} + {{ _('Off') }} + {% endif %} + +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/admin/transactions.html b/app/templates/admin/transactions.html index 607db5b..7d438ff 100644 --- a/app/templates/admin/transactions.html +++ b/app/templates/admin/transactions.html @@ -37,6 +37,7 @@ {{ _('Amount') }} {{ _('Status') }} {{ _('Stripe ID') }} + {% for txn in pagination.items %} @@ -46,6 +47,16 @@ ${{ '%.2f'|format(txn.amount_cents / 100) }} {{ txn.status }} {{ txn.stripe_object_id or '—' }} + + {% if txn.status == 'succeeded' and txn.type != 'refund' %} +
+ + +
+ {% endif %} + {% else %} {{ _('No transactions.') }} diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e038d0a..b24fc9c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -10,7 +10,7 @@ import os import re import io import logging - +import shutil import tempfile 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): os.remove(_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("FLASK_CONFIG", "dev") @@ -273,7 +278,7 @@ def _phase4(app): user_fresh = db.session.get(User, user.id) assert user_fresh.role == Role.subscriber 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 --- bsvc.downgrade_to_free(user.id) @@ -659,7 +664,8 @@ def _phase6(app): login("t@example.com", "NewPass456") for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings", "/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 print("non-admin /admin* -> 403: ok") c.get("/auth/logout", base_url=B) @@ -674,7 +680,11 @@ def _phase6(app): "/admin/listings", "/admin/reports", "/admin/settings", "/admin/categories", f"/admin/categories/{cat_id}/schema", "/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 assert code == 200, f"{path} -> {code}" print(f"{code} {path}") @@ -993,7 +1003,9 @@ def _phase2(app): db.session.add(img); db.session.commit() assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600 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 print("image pipeline (re-encode + thumbnail + EXIF strip): ok")