06/16 Phase 6 (continue)
This commit is contained in:
+21
-1
@@ -1,8 +1,13 @@
|
||||
"""Application factory."""
|
||||
from flask import Flask, render_template
|
||||
from flask import Flask, render_template, request
|
||||
from flask_login import current_user
|
||||
from app.config import get_config
|
||||
from app.extensions import (db, migrate, login_manager, csrf, babel, limiter)
|
||||
|
||||
_MAINTENANCE_EXEMPT_ENDPOINTS = {
|
||||
"static", "main.healthz", "auth.login", "auth.logout", "payments.webhook",
|
||||
}
|
||||
|
||||
|
||||
def create_app(config_object=None):
|
||||
app = Flask(__name__)
|
||||
@@ -14,6 +19,7 @@ def create_app(config_object=None):
|
||||
_register_blueprints(app)
|
||||
_register_errorhandlers(app)
|
||||
_register_context(app)
|
||||
_register_hooks(app)
|
||||
_register_cli(app)
|
||||
|
||||
return app
|
||||
@@ -115,6 +121,20 @@ def _register_context(app):
|
||||
}
|
||||
|
||||
|
||||
def _register_hooks(app):
|
||||
@app.before_request
|
||||
def check_maintenance():
|
||||
from app.services.settings import get_setting
|
||||
if not get_setting("maintenance_mode", False):
|
||||
return None
|
||||
if current_user.is_authenticated and getattr(current_user, "is_admin", False):
|
||||
return None
|
||||
ep = request.endpoint or ""
|
||||
if ep in _MAINTENANCE_EXEMPT_ENDPOINTS or ep.startswith("admin."):
|
||||
return None
|
||||
return render_template("errors/maintenance.html"), 503
|
||||
|
||||
|
||||
def _register_cli(app):
|
||||
@app.cli.command("expire-listings")
|
||||
def expire_listings_cmd():
|
||||
|
||||
+217
-37
@@ -1,5 +1,6 @@
|
||||
"""Admin backend: dashboard KPIs + user management. Admin-only."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
|
||||
from flask_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
|
||||
@@ -7,9 +8,11 @@ from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.models.listing import Listing
|
||||
from app.models.plan import Plan
|
||||
from app.models.category import Category
|
||||
from app.models.trust import TrustEvent
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.report import Report
|
||||
from app.models.payments import Transaction
|
||||
from app.models.enums import UserStatus, TrustEventType, ListingStatus
|
||||
from app.utils import admin_required
|
||||
from app.services import admin_users as usvc
|
||||
@@ -23,6 +26,9 @@ admin_bp = Blueprint("admin", __name__)
|
||||
PER_PAGE = 25
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin")
|
||||
@admin_required
|
||||
def dashboard():
|
||||
@@ -37,6 +43,9 @@ def dashboard():
|
||||
return render_template("admin/dashboard.html", kpis=kpis)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User management
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/users")
|
||||
@admin_required
|
||||
def users():
|
||||
@@ -53,7 +62,7 @@ def users():
|
||||
@admin_bp.route("/admin/users/<int:user_id>")
|
||||
@admin_required
|
||||
def user_detail(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
listings = (Listing.query.filter_by(user_id=user.id)
|
||||
.order_by(Listing.created_at.desc()).limit(50).all())
|
||||
trust_events = (user.trust_events.order_by(TrustEvent.created_at.desc())
|
||||
@@ -76,7 +85,7 @@ def _guard_self_action(user):
|
||||
@admin_bp.route("/admin/users/<int:user_id>/ban", methods=["POST"])
|
||||
@admin_required
|
||||
def ban_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
if _guard_self_action(user):
|
||||
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||
usvc.set_status(user, UserStatus.banned, actor=current_user)
|
||||
@@ -88,7 +97,7 @@ def ban_user(user_id):
|
||||
@admin_bp.route("/admin/users/<int:user_id>/suspend", methods=["POST"])
|
||||
@admin_required
|
||||
def suspend_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
if _guard_self_action(user):
|
||||
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||
usvc.set_status(user, UserStatus.suspended, actor=current_user)
|
||||
@@ -100,7 +109,7 @@ def suspend_user(user_id):
|
||||
@admin_bp.route("/admin/users/<int:user_id>/activate", methods=["POST"])
|
||||
@admin_required
|
||||
def activate_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
usvc.set_status(user, UserStatus.active, actor=current_user)
|
||||
db.session.commit()
|
||||
flash(_("User activated."), "success")
|
||||
@@ -110,7 +119,7 @@ def activate_user(user_id):
|
||||
@admin_bp.route("/admin/users/<int:user_id>/tier", methods=["POST"])
|
||||
@admin_required
|
||||
def set_tier(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
plan_id = request.form.get("plan_id", type=int)
|
||||
plan = db.session.get(Plan, plan_id) if plan_id else None
|
||||
usvc.set_tier(user, plan, actor=current_user)
|
||||
@@ -122,7 +131,7 @@ def set_tier(user_id):
|
||||
@admin_bp.route("/admin/users/<int:user_id>/trust", methods=["POST"])
|
||||
@admin_required
|
||||
def trust_adjust(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
user = db.get_or_404(User, user_id)
|
||||
delta = request.form.get("delta", type=int) or 0
|
||||
event_type = request.form.get("event_type", type=str)
|
||||
usvc.adjust_trust(user, TrustEventType(event_type), delta, actor=current_user)
|
||||
@@ -131,7 +140,9 @@ def trust_adjust(user_id):
|
||||
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||
|
||||
|
||||
# --- listing moderation ---
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listing moderation
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/listings")
|
||||
@admin_required
|
||||
def listings():
|
||||
@@ -144,25 +155,10 @@ def listings():
|
||||
keyword_blocklist=keyword_blocklist)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/listings/settings", methods=["POST"])
|
||||
@admin_required
|
||||
def listings_settings():
|
||||
threshold = request.form.get("flag_threshold", type=int) or 5
|
||||
raw_blocklist = request.form.get("keyword_blocklist", "")
|
||||
blocklist = [line.strip() for line in raw_blocklist.splitlines() if line.strip()]
|
||||
set_setting("flag_threshold", threshold)
|
||||
set_setting("keyword_blocklist", blocklist)
|
||||
audit.log_action(current_user, "settings.updated", "setting", None,
|
||||
meta={"flag_threshold": threshold, "keyword_blocklist": blocklist})
|
||||
db.session.commit()
|
||||
flash(_("Moderation settings updated."), "success")
|
||||
return redirect(url_for("admin.listings"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/listings/<int:listing_id>/approve", methods=["POST"])
|
||||
@admin_required
|
||||
def approve_listing(listing_id):
|
||||
listing = Listing.query.get_or_404(listing_id)
|
||||
listing = db.get_or_404(Listing, listing_id)
|
||||
msvc.approve(listing, actor=current_user)
|
||||
db.session.commit()
|
||||
flash(_("Listing approved."), "success")
|
||||
@@ -172,7 +168,7 @@ def approve_listing(listing_id):
|
||||
@admin_bp.route("/admin/listings/<int:listing_id>/hide", methods=["POST"])
|
||||
@admin_required
|
||||
def hide_listing(listing_id):
|
||||
listing = Listing.query.get_or_404(listing_id)
|
||||
listing = db.get_or_404(Listing, listing_id)
|
||||
msvc.hide(listing, actor=current_user)
|
||||
db.session.commit()
|
||||
flash(_("Listing hidden."), "warning")
|
||||
@@ -182,32 +178,45 @@ def hide_listing(listing_id):
|
||||
@admin_bp.route("/admin/listings/<int:listing_id>/remove", methods=["POST"])
|
||||
@admin_required
|
||||
def remove_listing(listing_id):
|
||||
listing = Listing.query.get_or_404(listing_id)
|
||||
listing = db.get_or_404(Listing, listing_id)
|
||||
msvc.remove(listing, actor=current_user)
|
||||
db.session.commit()
|
||||
flash(_("Listing removed."), "danger")
|
||||
return redirect(url_for("admin.listings"))
|
||||
|
||||
|
||||
# --- reports queue ---
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reports queue
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/reports")
|
||||
@admin_required
|
||||
def reports():
|
||||
candidates = (Report.query.join(Listing, Report.listing_id == Listing.id)
|
||||
.filter(Listing.status == ListingStatus.flagged)
|
||||
.order_by(Report.created_at.desc()).all())
|
||||
dismissed_ids = {a.target_id for a in
|
||||
AuditLog.query.filter_by(target_type="report", action="report.dismissed").all()}
|
||||
open_reports = [r for r in candidates if r.id not in dismissed_ids]
|
||||
dismissed_subq = (
|
||||
db.session.query(AuditLog.target_id)
|
||||
.filter(AuditLog.target_type == "report",
|
||||
AuditLog.action == "report.dismissed")
|
||||
.scalar_subquery()
|
||||
)
|
||||
open_reports = (
|
||||
Report.query
|
||||
.join(Listing, Report.listing_id == Listing.id)
|
||||
.filter(
|
||||
Listing.status.in_([ListingStatus.flagged, ListingStatus.active]),
|
||||
Report.id.not_in(dismissed_subq),
|
||||
)
|
||||
.order_by(Report.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return render_template("admin/reports.html", reports=open_reports)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/reports/<int:report_id>/dismiss", methods=["POST"])
|
||||
@admin_required
|
||||
def dismiss_report(report_id):
|
||||
report = Report.query.get_or_404(report_id)
|
||||
report = db.get_or_404(Report, report_id)
|
||||
audit.log_action(current_user, "report.dismissed", "report", report.id,
|
||||
meta={"listing_id": report.listing_id, "reporter_id": report.reporter_id})
|
||||
meta={"listing_id": report.listing_id,
|
||||
"reporter_id": report.reporter_id})
|
||||
db.session.commit()
|
||||
flash(_("Report dismissed."), "info")
|
||||
return redirect(url_for("admin.reports"))
|
||||
@@ -216,9 +225,180 @@ def dismiss_report(report_id):
|
||||
@admin_bp.route("/admin/reports/<int:report_id>/escalate", methods=["POST"])
|
||||
@admin_required
|
||||
def escalate_report(report_id):
|
||||
report = Report.query.get_or_404(report_id)
|
||||
report = db.get_or_404(Report, report_id)
|
||||
audit.log_action(current_user, "report.escalated", "report", report.id,
|
||||
meta={"listing_id": report.listing_id, "reporter_id": report.reporter_id})
|
||||
meta={"listing_id": report.listing_id,
|
||||
"reporter_id": report.reporter_id})
|
||||
db.session.commit()
|
||||
flash(_("Report escalated."), "warning")
|
||||
return redirect(url_for("admin.reports"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# General settings
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/settings", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def settings():
|
||||
if request.method == "POST":
|
||||
raw_blocklist = request.form.get("keyword_blocklist", "")
|
||||
values = {
|
||||
"registration_open": request.form.get("registration_open") == "on",
|
||||
"ads_enabled": request.form.get("ads_enabled") == "on",
|
||||
"maintenance_mode": request.form.get("maintenance_mode") == "on",
|
||||
"flag_threshold": request.form.get("flag_threshold", type=int) or 5,
|
||||
"new_user_trust_gate_days": request.form.get("new_user_trust_gate_days", type=int) or 0,
|
||||
"contact_density_threshold": request.form.get("contact_density_threshold", type=int) or 3,
|
||||
"keyword_blocklist": [l.strip() for l in raw_blocklist.splitlines() if l.strip()],
|
||||
}
|
||||
for key, value in values.items():
|
||||
set_setting(key, value)
|
||||
audit.log_action(current_user, "settings.updated", "setting", None, meta=values)
|
||||
db.session.commit()
|
||||
flash(_("Settings updated."), "success")
|
||||
return redirect(url_for("admin.settings"))
|
||||
|
||||
values = {
|
||||
"registration_open": get_setting("registration_open", True),
|
||||
"ads_enabled": get_setting("ads_enabled", True),
|
||||
"maintenance_mode": get_setting("maintenance_mode", False),
|
||||
"flag_threshold": get_setting("flag_threshold", 5),
|
||||
"new_user_trust_gate_days": get_setting("new_user_trust_gate_days", 0),
|
||||
"contact_density_threshold": get_setting("contact_density_threshold", 3),
|
||||
"keyword_blocklist": get_setting("keyword_blocklist", []),
|
||||
}
|
||||
return render_template("admin/settings.html", values=values)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit log
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/audit")
|
||||
@admin_required
|
||||
def audit_log():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
action_filter = request.args.get("action", type=str) or None
|
||||
actor_filter = request.args.get("actor", type=int) or None
|
||||
|
||||
q = AuditLog.query.order_by(AuditLog.created_at.desc())
|
||||
if action_filter:
|
||||
q = q.filter(AuditLog.action.like(f"%{action_filter}%"))
|
||||
if actor_filter:
|
||||
q = q.filter(AuditLog.actor_id == actor_filter)
|
||||
|
||||
pagination = q.paginate(page=page, per_page=PER_PAGE, error_out=False)
|
||||
return render_template("admin/audit.html", pagination=pagination,
|
||||
filters=request.args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transactions log
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/transactions")
|
||||
@admin_required
|
||||
def transactions():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
txn_type = request.args.get("type", type=str) or None
|
||||
txn_status = request.args.get("status", type=str) or None
|
||||
|
||||
q = Transaction.query.order_by(Transaction.created_at.desc())
|
||||
if txn_type:
|
||||
q = q.filter(Transaction.type == txn_type)
|
||||
if txn_status:
|
||||
q = q.filter(Transaction.status == txn_status)
|
||||
|
||||
pagination = q.paginate(page=page, per_page=PER_PAGE, error_out=False)
|
||||
total_cents = db.session.query(
|
||||
db.func.sum(Transaction.amount_cents)
|
||||
).filter(Transaction.status == "succeeded").scalar() or 0
|
||||
return render_template("admin/transactions.html", pagination=pagination,
|
||||
filters=request.args, total_cents=total_cents)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category management
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/categories")
|
||||
@admin_required
|
||||
def categories():
|
||||
top_level = (Category.query.filter_by(parent_id=None)
|
||||
.order_by(Category.sort_order, Category.name).all())
|
||||
return render_template("admin/categories.html", categories=top_level)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/categories/<int:cat_id>/toggle", methods=["POST"])
|
||||
@admin_required
|
||||
def toggle_category(cat_id):
|
||||
cat = db.get_or_404(Category, cat_id)
|
||||
cat.is_active = not cat.is_active
|
||||
audit.log_action(current_user, "category.toggled", "category", cat.id,
|
||||
meta={"is_active": cat.is_active})
|
||||
db.session.commit()
|
||||
flash(_("Category %(name)s %(state)s.", name=cat.name,
|
||||
state=_("enabled") if cat.is_active else _("disabled")), "success")
|
||||
return redirect(url_for("admin.categories"))
|
||||
|
||||
|
||||
@admin_bp.route("/admin/categories/<int:cat_id>/schema", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def category_schema(cat_id):
|
||||
cat = db.get_or_404(Category, cat_id)
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
raw = request.form.get("field_schema", "")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("must be a JSON object")
|
||||
cat.field_schema = parsed
|
||||
audit.log_action(current_user, "category.schema_updated",
|
||||
"category", cat.id)
|
||||
db.session.commit()
|
||||
flash(_("Field schema updated."), "success")
|
||||
return redirect(url_for("admin.categories"))
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
error = str(exc)
|
||||
schema_str = json.dumps(cat.field_schema or {}, indent=2)
|
||||
return render_template("admin/category_schema.html", cat=cat,
|
||||
schema_str=schema_str, error=error)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan management
|
||||
# ---------------------------------------------------------------------------
|
||||
@admin_bp.route("/admin/plans")
|
||||
@admin_required
|
||||
def plans():
|
||||
all_plans = Plan.query.order_by(Plan.sort_order).all()
|
||||
return render_template("admin/plans.html", plans=all_plans)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/plans/<int:plan_id>", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def plan_edit(plan_id):
|
||||
plan = db.get_or_404(Plan, plan_id)
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
price_str = request.form.get("price_monthly_cents", "0")
|
||||
stripe_price_id = request.form.get("stripe_price_id", "").strip() or None
|
||||
config_raw = request.form.get("config", "")
|
||||
try:
|
||||
price_cents = int(price_str)
|
||||
config_parsed = json.loads(config_raw)
|
||||
if not isinstance(config_parsed, dict):
|
||||
raise ValueError("config must be a JSON object")
|
||||
plan.name = name
|
||||
plan.price_monthly_cents = price_cents
|
||||
plan.stripe_price_id = stripe_price_id
|
||||
plan.config = config_parsed
|
||||
audit.log_action(current_user, "plan.updated", "plan", plan.id,
|
||||
meta={"name": name, "price_cents": price_cents})
|
||||
db.session.commit()
|
||||
flash(_("Plan updated."), "success")
|
||||
return redirect(url_for("admin.plans"))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
error = str(exc)
|
||||
config_str = json.dumps(plan.config or {}, indent=2)
|
||||
return render_template("admin/plan_edit.html", plan=plan,
|
||||
config_str=config_str, error=error)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.extensions import db
|
||||
from app.models.ads import Ad
|
||||
from app.services.ads import (get_ad, record_impression, record_click,
|
||||
active_sponsors)
|
||||
from app.services.settings import get_setting
|
||||
|
||||
ads_bp = Blueprint("ads", __name__)
|
||||
|
||||
@@ -43,6 +44,9 @@ def inject_ads():
|
||||
Returns ad slots for use in templates.
|
||||
Ads suppressed for subscribers (ad_free plan limit).
|
||||
"""
|
||||
if not get_setting("ads_enabled", True):
|
||||
return {"ads": {}, "show_ads": False}
|
||||
|
||||
show_ads = True
|
||||
if current_user.is_authenticated:
|
||||
plan = current_user.tier
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.utils.security import generate_token, read_token
|
||||
from app.services.email import send_email
|
||||
from app.services.turnstile import verify_turnstile, turnstile_enabled
|
||||
from app.services.trust import record_event
|
||||
from app.services.settings import get_setting
|
||||
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
|
||||
ResetRequestForm, ResetForm)
|
||||
|
||||
@@ -36,6 +37,10 @@ def register():
|
||||
return redirect(url_for("main.index"))
|
||||
form = RegisterForm()
|
||||
if form.validate_on_submit():
|
||||
if not get_setting("registration_open", True):
|
||||
flash(_("Registration is currently closed."), "danger")
|
||||
return render_template("auth/register.html", form=form,
|
||||
turnstile=turnstile_enabled())
|
||||
if not verify_turnstile():
|
||||
flash(_("Captcha verification failed."), "danger")
|
||||
return render_template("auth/register.html", form=form,
|
||||
|
||||
+10
-2
@@ -9,7 +9,9 @@ For the *sending* side we heuristic-flag high-contact-density messages so
|
||||
moderators can spot scraping attempts.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime
|
||||
from app.models.enums import TrustTier
|
||||
from app.services.settings import get_setting
|
||||
|
||||
# patterns
|
||||
_PHONE_RE = re.compile(
|
||||
@@ -25,8 +27,14 @@ def contact_revealed(user) -> bool:
|
||||
"""True when this user's contact info may be shown unmasked."""
|
||||
if user is None:
|
||||
return False
|
||||
return (user.email_verified and
|
||||
user.trust_tier in (TrustTier.trusted, TrustTier.verified))
|
||||
if not (user.email_verified and
|
||||
user.trust_tier in (TrustTier.trusted, TrustTier.verified)):
|
||||
return False
|
||||
gate_days = get_setting("new_user_trust_gate_days", 0)
|
||||
if gate_days and user.created_at:
|
||||
if (datetime.utcnow() - user.created_at).days < gate_days:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mask_body(text: str, reveal: bool = False) -> str:
|
||||
|
||||
@@ -14,9 +14,7 @@ from app.extensions import db
|
||||
from app.models.messaging import Conversation, Message
|
||||
from app.services.contact import contact_density
|
||||
from app.services.email import send_email
|
||||
|
||||
# Bodies with ≥ 3 contact signals get auto-flagged
|
||||
_FLAG_THRESHOLD = 3
|
||||
from app.services.settings import get_setting
|
||||
|
||||
|
||||
class MessagingError(ValueError):
|
||||
@@ -60,7 +58,8 @@ def send_message(conversation, sender, body: str) -> Message:
|
||||
if sender.id not in (conversation.buyer_id, conversation.seller_id):
|
||||
raise MessagingError("not a participant")
|
||||
|
||||
flagged = contact_density(body) >= _FLAG_THRESHOLD
|
||||
threshold = get_setting("contact_density_threshold", 3)
|
||||
flagged = contact_density(body) >= threshold
|
||||
|
||||
msg = Message(
|
||||
conversation_id=conversation.id,
|
||||
|
||||
@@ -19,7 +19,7 @@ def create_report(listing, reporter, reason: ReportReason, note=None):
|
||||
reason=reason, note=(note or "").strip() or None)
|
||||
db.session.add(report)
|
||||
try:
|
||||
db.session.commit()
|
||||
db.session.flush()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
raise ReportError("you have already reported this listing")
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
<a href="{{ url_for('admin.users') }}" class="{{ 'on' if request.endpoint in ('admin.users', 'admin.user_detail') else '' }}">{{ _('Users') }}</a>
|
||||
<a href="{{ url_for('admin.listings') }}" class="{{ 'on' if request.endpoint == 'admin.listings' else '' }}">{{ _('Listings') }}</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.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a>
|
||||
<a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</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>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Audit log') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Audit log') }}</h2>
|
||||
|
||||
<form method="get" class="admin-filter-row">
|
||||
<div class="field">
|
||||
<label>{{ _('Action filter') }}</label>
|
||||
<input class="input" name="action" value="{{ filters.get('action', '') }}" placeholder="e.g. listing.removed">
|
||||
</div>
|
||||
<button class="btn ghost" type="submit">{{ _('Filter') }}</button>
|
||||
{% if filters.get('action') %}<a class="btn ghost" href="{{ url_for('admin.audit_log') }}">{{ _('Clear') }}</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('When') }}</th>
|
||||
<th>{{ _('Actor') }}</th>
|
||||
<th>{{ _('Action') }}</th>
|
||||
<th>{{ _('Target') }}</th>
|
||||
<th>{{ _('Meta') }}</th>
|
||||
</tr></thead>
|
||||
{% for entry in pagination.items %}
|
||||
<tr>
|
||||
<td class="muted small">{{ entry.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if entry.actor %}
|
||||
<a href="{{ url_for('admin.user_detail', user_id=entry.actor_id) }}">{{ entry.actor.display_name }}</a>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td><code>{{ entry.action }}</code></td>
|
||||
<td class="muted small">{{ entry.target_type }}{% if entry.target_id %}:{{ entry.target_id }}{% endif %}</td>
|
||||
<td class="muted small">{{ entry.meta or '' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">{{ _('No entries.') }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<div class="pager">
|
||||
{% if pagination.has_prev %}<a href="{{ url_for('admin.audit_log', **merge_query(page=pagination.prev_num)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||
<span class="muted">{{ _('Page %(p)s of %(t)s', p=pagination.page, t=pagination.pages) }}</span>
|
||||
{% if pagination.has_next %}<a href="{{ url_for('admin.audit_log', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Categories') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Categories') }}</h2>
|
||||
<table class="list">
|
||||
{% for cat in categories %}
|
||||
<tr>
|
||||
<td><strong>{{ cat.name }}</strong> <code class="muted small">{{ cat.slug }}</code></td>
|
||||
<td><span class="badge {{ 'ok' if cat.is_active else 'warn' }}">{{ _('active') if cat.is_active else _('inactive') }}</span></td>
|
||||
<td class="muted small">{{ cat.children|length }} {{ _('subcategories') }}</td>
|
||||
<td>
|
||||
<a class="btn ghost tiny" href="{{ url_for('admin.category_schema', cat_id=cat.id) }}">{{ _('Field schema') }}</a>
|
||||
<form method="post" action="{{ url_for('admin.toggle_category', cat_id=cat.id) }}" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost tiny" type="submit">{{ _('Disable') if cat.is_active else _('Enable') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% for sub in cat.children|sort(attribute='sort_order') %}
|
||||
<tr style="background:var(--bg2)">
|
||||
<td style="padding-left:24px">{{ sub.name }} <code class="muted small">{{ sub.slug }}</code></td>
|
||||
<td><span class="badge {{ 'ok' if sub.is_active else 'warn' }}">{{ _('active') if sub.is_active else _('inactive') }}</span></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="btn ghost tiny" href="{{ url_for('admin.category_schema', cat_id=sub.id) }}">{{ _('Field schema') }}</a>
|
||||
<form method="post" action="{{ url_for('admin.toggle_category', cat_id=sub.id) }}" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost tiny" type="submit">{{ _('Disable') if sub.is_active else _('Enable') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · %(name)s schema', name=cat.name) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Field schema: %(name)s', name=cat.name) }}</h2>
|
||||
<p class="muted">{{ _('Edit the JSON field schema for this category. Supported types: text, number, select, bool. Add "hot": true to denormalize onto an indexed column.') }}</p>
|
||||
{% if error %}<p class="flash danger">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="{{ url_for('admin.category_schema', cat_id=cat.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="field">
|
||||
<textarea class="input" name="field_schema" rows="20" style="font-family:monospace;font-size:13px">{{ schema_str }}</textarea>
|
||||
</div>
|
||||
<button class="btn" type="submit">{{ _('Save schema') }}</button>
|
||||
<a class="btn ghost" href="{{ url_for('admin.categories') }}">{{ _('Cancel') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,19 +3,10 @@
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Moderation settings') }}</h2>
|
||||
<form method="post" action="{{ url_for('admin.listings_settings') }}" class="settings-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="field">
|
||||
<label>{{ _('Auto-flag threshold (distinct reports)') }}</label>
|
||||
<input class="input" type="number" name="flag_threshold" value="{{ flag_threshold }}" min="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Keyword blocklist (one per line)') }}</label>
|
||||
<textarea class="input" name="keyword_blocklist">{{ keyword_blocklist|join('\n') }}</textarea>
|
||||
</div>
|
||||
<button class="btn" type="submit">{{ _('Save settings') }}</button>
|
||||
</form>
|
||||
<p class="muted">
|
||||
{{ _('Auto-flag threshold: %(n)s distinct reports · Blocklist: %(k)s keywords', n=flag_threshold, k=keyword_blocklist|length) }}
|
||||
· <a href="{{ url_for('admin.settings') }}">{{ _('Edit in Settings') }}</a>
|
||||
</p>
|
||||
|
||||
<h2>{{ _('Flag queue') }}</h2>
|
||||
{% if not pagination.items %}<p class="muted">{{ _('No flagged listings.') }}</p>{% endif %}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Edit plan: %(slug)s', slug=plan.slug) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Edit plan: %(name)s', name=plan.name) }}</h2>
|
||||
{% if error %}<p class="flash danger">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="{{ url_for('admin.plan_edit', plan_id=plan.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="field">
|
||||
<label>{{ _('Name') }}</label>
|
||||
<input class="input" name="name" value="{{ plan.name }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Price (cents per month — integer, e.g. 999 = $9.99)') }}</label>
|
||||
<input class="input" type="number" name="price_monthly_cents" value="{{ plan.price_monthly_cents }}" min="0" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Stripe price ID') }}</label>
|
||||
<input class="input" name="stripe_price_id" value="{{ plan.stripe_price_id or '' }}" placeholder="price_xxx">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Config JSON (limits)') }}</label>
|
||||
<textarea class="input" name="config" rows="20" style="font-family:monospace;font-size:13px">{{ config_str }}</textarea>
|
||||
</div>
|
||||
<button class="btn" type="submit">{{ _('Save plan') }}</button>
|
||||
<a class="btn ghost" href="{{ url_for('admin.plans') }}">{{ _('Cancel') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Plans') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Plans') }}</h2>
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('Slug') }}</th>
|
||||
<th>{{ _('Name') }}</th>
|
||||
<th>{{ _('Price / mo') }}</th>
|
||||
<th>{{ _('Stripe price ID') }}</th>
|
||||
<th>{{ _('Active') }}</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
{% for plan in plans %}
|
||||
<tr>
|
||||
<td><code>{{ plan.slug }}</code></td>
|
||||
<td>{{ plan.name }}</td>
|
||||
<td>${{ '%.2f'|format(plan.price_monthly_cents / 100) }}</td>
|
||||
<td class="muted small">{{ plan.stripe_price_id or '—' }}</td>
|
||||
<td><span class="badge {{ 'ok' if plan.is_active else 'warn' }}">{{ _('yes') if plan.is_active else _('no') }}</span></td>
|
||||
<td><a class="btn ghost tiny" href="{{ url_for('admin.plan_edit', plan_id=plan.id) }}">{{ _('Edit') }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Settings') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Settings') }}</h2>
|
||||
<form method="post" action="{{ url_for('admin.settings') }}" class="settings-panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="check">
|
||||
<input type="checkbox" name="registration_open" id="registration_open" {{ 'checked' if values.registration_open }}>
|
||||
<label for="registration_open">{{ _('Registration open') }}</label>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" name="ads_enabled" id="ads_enabled" {{ 'checked' if values.ads_enabled }}>
|
||||
<label for="ads_enabled">{{ _('Ads enabled') }}</label>
|
||||
</div>
|
||||
<div class="check">
|
||||
<input type="checkbox" name="maintenance_mode" id="maintenance_mode" {{ 'checked' if values.maintenance_mode }}>
|
||||
<label for="maintenance_mode">{{ _('Maintenance mode') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ _('Auto-flag threshold (distinct reports)') }}</label>
|
||||
<input class="input" type="number" name="flag_threshold" value="{{ values.flag_threshold }}" min="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('New-user trust gate (days)') }}</label>
|
||||
<input class="input" type="number" name="new_user_trust_gate_days" value="{{ values.new_user_trust_gate_days }}" min="0">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Contact-density flag threshold (signals per message)') }}</label>
|
||||
<input class="input" type="number" name="contact_density_threshold" value="{{ values.contact_density_threshold }}" min="1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Keyword blocklist (one per line)') }}</label>
|
||||
<textarea class="input" name="keyword_blocklist">{{ values.keyword_blocklist|join('\n') }}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit">{{ _('Save settings') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Transactions') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Transactions') }}</h2>
|
||||
<p class="muted">{{ _('All-time revenue (succeeded): $%(amount)s', amount='%.2f'|format(total_cents / 100)) }}</p>
|
||||
|
||||
<form method="get" class="admin-filter-row">
|
||||
<div class="field">
|
||||
<label>{{ _('Type') }}</label>
|
||||
<select class="input" name="type">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
<option value="subscription" {{ 'selected' if filters.get('type') == 'subscription' }}>{{ _('Subscription') }}</option>
|
||||
<option value="boost" {{ 'selected' if filters.get('type') == 'boost' }}>{{ _('Boost') }}</option>
|
||||
<option value="refund" {{ 'selected' if filters.get('type') == 'refund' }}>{{ _('Refund') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Status') }}</label>
|
||||
<select class="input" name="status">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
<option value="succeeded" {{ 'selected' if filters.get('status') == 'succeeded' }}>{{ _('Succeeded') }}</option>
|
||||
<option value="failed" {{ 'selected' if filters.get('status') == 'failed' }}>{{ _('Failed') }}</option>
|
||||
<option value="pending" {{ 'selected' if filters.get('status') == 'pending' }}>{{ _('Pending') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn ghost" type="submit">{{ _('Filter') }}</button>
|
||||
{% if filters.get('type') or filters.get('status') %}<a class="btn ghost" href="{{ url_for('admin.transactions') }}">{{ _('Clear') }}</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<table class="list">
|
||||
<thead><tr>
|
||||
<th>{{ _('Date') }}</th>
|
||||
<th>{{ _('User') }}</th>
|
||||
<th>{{ _('Type') }}</th>
|
||||
<th>{{ _('Amount') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th>{{ _('Stripe ID') }}</th>
|
||||
</tr></thead>
|
||||
{% for txn in pagination.items %}
|
||||
<tr>
|
||||
<td class="muted small">{{ txn.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td><a href="{{ url_for('admin.user_detail', user_id=txn.user_id) }}">{{ txn.user.display_name }}</a></td>
|
||||
<td><span class="badge cat">{{ txn.type }}</span></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 class="muted small">{{ txn.stripe_object_id or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<div class="pager">
|
||||
{% if pagination.has_prev %}<a href="{{ url_for('admin.transactions', **merge_query(page=pagination.prev_num)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||
<span class="muted">{{ _('Page %(p)s of %(t)s', p=pagination.page, t=pagination.pages) }}</span>
|
||||
{% if pagination.has_next %}<a href="{{ url_for('admin.transactions', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Maintenance') }}{% endblock %}
|
||||
{% block content %}<div class="card narrow"><h2>{{ _('Down for maintenance') }}</h2><p>{{ _('Check back soon.') }}</p></div>{% endblock %}
|
||||
Reference in New Issue
Block a user