06/16 Phase 6 (continue)
This commit is contained in:
@@ -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"))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user