06/16 Phase 6 (continue)

This commit is contained in:
2026-06-17 17:06:43 -04:00
parent 479afde2fa
commit 68a842d6b6
26 changed files with 1027 additions and 61 deletions
+21 -1
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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:
+3 -4
View File
@@ -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,
+1 -1
View File
@@ -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")
+5
View File
@@ -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>
+48
View File
@@ -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)) }}">&larr; {{ _('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') }} &rarr;</a>{% endif %}
</div>
</div>
{% endblock %}
+38
View File
@@ -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 %}
+18
View File
@@ -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 %}
+4 -13
View File
@@ -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 %}
+30
View File
@@ -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 %}
+28
View File
@@ -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 %}
+43
View File
@@ -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 %}
+61
View File
@@ -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)) }}">&larr; {{ _('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') }} &rarr;</a>{% endif %}
</div>
</div>
{% endblock %}
+3
View File
@@ -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 %}
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 0 session .eJyNzjtuwzAMANCrBJyFQrRIfXyNjkFgiBTVBC3SwpKnIHfPkAt0f8N7wNZ_6rjagPX8gNOE9QzjULUxwMGnzXm7f43T8dfqtPYBl6f7H7s42Ppu4wrr3A9zsN0arLA0pBxJqyqJaFBPkWJIMSymWNEjYeYatHLv7KsV9d1QStJEgSU2zmGJS9OOlbH6UHpGscWYNGXxuYXuW-CkNbIPVBZsXnoVn7PkBg62Y9j-3iA40LH3bf5-2x1W0Jwi1U7ZsxIXQTFDjdwbC4uqJ2XTVuD5AjkLXsw.ajG0hA.buMxzsCsqNH4KUARrDD5qwouuvw
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 0 session eyJfZnJlc2giOmZhbHNlLCJjc3JmX3Rva2VuIjoiMmFhNDVhYjE4MjViNDYzMjFmZTBiZmIxZTA5OTVkN2VkYjllZTZiMyJ9.ajG0hQ.C1J4cQYfjj9l__jJf9fFpdKAvSw
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
* Serving Flask app 'wsgi:app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5088
Press CTRL+C to quit
127.0.0.1 - - [16/Jun/2026 16:38:55] "GET /healthz HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:09] "GET /auth/login HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:10] "POST /auth/login HTTP/1.1" 302 -
127.0.0.1 - - [16/Jun/2026 16:39:10] "GET /admin/settings HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:20] "POST /admin/settings HTTP/1.1" 302 -
127.0.0.1 - - [16/Jun/2026 16:39:20] "GET /admin/settings HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:32] "POST /admin/settings HTTP/1.1" 302 -
127.0.0.1 - - [16/Jun/2026 16:39:33] "GET /auth/register HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:33] "POST /auth/register HTTP/1.1" 200 -
127.0.0.1 - - [16/Jun/2026 16:39:43] "GET / HTTP/1.1" 200 -
+61
View File
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Classifieds — Home</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">Classifieds</a>
<nav class="nav">
<a href="/listings">Browse</a>
<a href="/pricing">Pricing</a>
<a href="/auth/login">Sign in</a>
<a class="btn" href="/auth/register">Register</a>
<span class="langs">
<a href="/lang/en"
class="on">EN</a>
<a href="/lang/vi"
class="">VI</a>
<a href="/lang/es"
class="">ES</a>
</span>
</nav>
</div>
</header>
<main class="wrap">
<section class="hero">
<h1>Find what you need. Post what you offer.</h1>
<p>Buy, sell, request, hire, and connect across your community.</p>
<a class="btn btn-lg" href="/listings">Browse listings</a>
<a class="btn btn-lg ghost" href="/auth/register">Get started</a>
</section>
</main>
<footer class="site-footer">
<div class="wrap">© 2025 Classifieds · <a href="/sponsors">Sponsors</a></div>
</footer>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Register</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">Classifieds</a>
<nav class="nav">
<a href="/listings">Browse</a>
<a href="/pricing">Pricing</a>
<a href="/auth/login">Sign in</a>
<a class="btn" href="/auth/register">Register</a>
<span class="langs">
<a href="/lang/en"
class="on">EN</a>
<a href="/lang/vi"
class="">VI</a>
<a href="/lang/es"
class="">ES</a>
</span>
</nav>
</div>
</header>
<main class="wrap">
<div class="flashes">
<div class="flash flash-danger">Registration is currently closed.</div>
</div>
<div class="card narrow">
<h2>Create account</h2>
<form method="post" novalidate>
<input id="csrf_token" name="csrf_token" type="hidden" value="IjJhYTQ1YWIxODI1YjQ2MzIxZmUwYmZiMWUwOTk1ZDdlZGI5ZWU2YjMi.ajG0hQ.cYOQILpu4BMWqB_IJXUSqljcaJM">
<div class="field">
<label for="display_name">Display name</label>
<input class="input" id="display_name" maxlength="80" minlength="2" name="display_name" required type="text" value="Nobody">
</div>
<div class="field">
<label for="email">Email</label>
<input class="input" id="email" maxlength="255" name="email" required type="text" value="nobody@example.com">
</div>
<div class="field">
<label for="password">Password</label>
<input class="input" id="password" maxlength="128" minlength="8" name="password" required type="password" value="">
</div>
<div class="field">
<label for="confirm">Confirm password</label>
<input class="input" id="confirm" name="confirm" required type="password" value="">
</div>
<input class="btn" id="submit" name="submit" type="submit" value="Create account">
</form>
<p class="muted"><a href="/auth/login">Already have an account? Sign in</a></p>
</div>
</main>
<footer class="site-footer">
<div class="wrap">© 2025 Classifieds · <a href="/sponsors">Sponsors</a></div>
</footer>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · Settings</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">Classifieds</a>
<nav class="nav">
<a href="/listings">Browse</a>
<a href="/pricing">Pricing</a>
<a href="/listings/new">Post</a>
<a href="/my/listings">My listings</a>
<a href="/my/favorites">Saved</a>
<a href="/messages" class="msg-link">
Messages
</a>
<a href="/my/billing">Billing</a>
<a href="/admin">Admin</a>
<span class="hi">Admin</span>
<a href="/auth/logout">Sign out</a>
<span class="langs">
<a href="/lang/en"
class="on">EN</a>
<a href="/lang/vi"
class="">VI</a>
<a href="/lang/es"
class="">ES</a>
</span>
</nav>
</div>
</header>
<main class="wrap">
<nav class="admin-nav">
<a href="/admin" class="">Dashboard</a>
<a href="/admin/users" class="">Users</a>
<a href="/admin/listings" class="">Listings</a>
<a href="/admin/reports" class="">Reports</a>
<a href="/admin/settings" class="on">Settings</a>
</nav>
<div class="card">
<h2>Settings</h2>
<form method="post" action="/admin/settings" class="settings-panel">
<input type="hidden" name="csrf_token" value="ImM4NzY0YWY0ODA1YzQ1OWIxYmVlMWM2NWZkNWI1YmNjMDRjNWVjZDki.ajG0bg.o_SI4nogl1a7VZBjP0l0Ox8W7Dc">
<div class="check">
<input type="checkbox" name="registration_open" id="registration_open" checked>
<label for="registration_open">Registration open</label>
</div>
<div class="check">
<input type="checkbox" name="ads_enabled" id="ads_enabled" checked>
<label for="ads_enabled">Ads enabled</label>
</div>
<div class="check">
<input type="checkbox" name="maintenance_mode" id="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="5" 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="0" 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="3" min="1">
</div>
<div class="field">
<label>Keyword blocklist (one per line)</label>
<textarea class="input" name="keyword_blocklist"></textarea>
</div>
<button class="btn" type="submit">Save settings</button>
</form>
</div>
</main>
<footer class="site-footer">
<div class="wrap">© 2025 Classifieds · <a href="/sponsors">Sponsors</a></div>
</footer>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin · Settings</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">Classifieds</a>
<nav class="nav">
<a href="/listings">Browse</a>
<a href="/pricing">Pricing</a>
<a href="/listings/new">Post</a>
<a href="/my/listings">My listings</a>
<a href="/my/favorites">Saved</a>
<a href="/messages" class="msg-link">
Messages
</a>
<a href="/my/billing">Billing</a>
<a href="/admin">Admin</a>
<span class="hi">Admin</span>
<a href="/auth/logout">Sign out</a>
<span class="langs">
<a href="/lang/en"
class="on">EN</a>
<a href="/lang/vi"
class="">VI</a>
<a href="/lang/es"
class="">ES</a>
</span>
</nav>
</div>
</header>
<main class="wrap">
<div class="flashes">
<div class="flash flash-success">Settings updated.</div>
</div>
<nav class="admin-nav">
<a href="/admin" class="">Dashboard</a>
<a href="/admin/users" class="">Users</a>
<a href="/admin/listings" class="">Listings</a>
<a href="/admin/reports" class="">Reports</a>
<a href="/admin/settings" class="on">Settings</a>
</nav>
<div class="card">
<h2>Settings</h2>
<form method="post" action="/admin/settings" class="settings-panel">
<input type="hidden" name="csrf_token" value="ImM4NzY0YWY0ODA1YzQ1OWIxYmVlMWM2NWZkNWI1YmNjMDRjNWVjZDki.ajG0eA.8uMke9GGNrakq9giSkOfvL-eS0k">
<div class="check">
<input type="checkbox" name="registration_open" id="registration_open" checked>
<label for="registration_open">Registration open</label>
</div>
<div class="check">
<input type="checkbox" name="ads_enabled" id="ads_enabled" checked>
<label for="ads_enabled">Ads enabled</label>
</div>
<div class="check">
<input type="checkbox" name="maintenance_mode" id="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="4" 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="0" 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="3" min="1">
</div>
<div class="field">
<label>Keyword blocklist (one per line)</label>
<textarea class="input" name="keyword_blocklist"></textarea>
</div>
<button class="btn" type="submit">Save settings</button>
</form>
</div>
</main>
<footer class="site-footer">
<div class="wrap">© 2025 Classifieds · <a href="/sponsors">Sponsors</a></div>
</footer>
</body>
</html>
+80 -3
View File
@@ -657,17 +657,24 @@ def _phase6(app):
# non-admin gets 403
login("t@example.com", "NewPass456")
for path in ("/admin", "/admin/listings", "/admin/reports"):
for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings",
"/admin/categories", "/admin/plans", "/admin/transactions",
"/admin/audit"):
assert c.get(path, base_url=B).status_code == 403
print("non-admin /admin* -> 403: ok")
c.get("/auth/logout", base_url=B)
# admin gets 200 on dashboard/users/user_detail/listings/reports
# admin gets 200 on all admin pages
login("admin@example.com", "AdminPass123")
with app.app_context():
target_id = User.query.filter_by(email="t@example.com").first().id
cat_id = Category.query.filter_by(parent_id=None).first().id
plan_id = Plan.query.filter_by(slug="free").first().id
for path in ("/admin", "/admin/users", f"/admin/users/{target_id}",
"/admin/listings", "/admin/reports"):
"/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"):
code = c.get(path, base_url=B).status_code
assert code == 200, f"{path} -> {code}"
print(f"{code} {path}")
@@ -715,6 +722,76 @@ def _phase6(app):
assert "HTTP Report Test" not in r2.get_data(as_text=True)
print("report dismiss hides from open queue: ok")
# --- contact_density_threshold: configurable via settings ---
from app.services.contact import contact_density, contact_revealed
with app.app_context():
body = "Call me at 555-123-4567"
assert contact_density(body) == 1
set_setting("contact_density_threshold", 1)
db.session.commit()
assert contact_density(body) >= get_setting("contact_density_threshold", 3)
set_setting("contact_density_threshold", 3)
db.session.commit()
assert not (contact_density(body) >= get_setting("contact_density_threshold", 3))
print("contact_density_threshold configurable: ok")
# --- new_user_trust_gate_days: blocks reveal even for trusted+verified ---
with app.app_context():
buyer = User.query.filter_by(email="buyer@example.com").first()
assert contact_revealed(buyer)
set_setting("new_user_trust_gate_days", 9999)
db.session.commit()
assert not contact_revealed(buyer)
set_setting("new_user_trust_gate_days", 0)
db.session.commit()
assert contact_revealed(buyer)
print("new_user_trust_gate_days gates contact reveal: ok")
# --- ads_enabled: short-circuits inject_ads() ---
from app.blueprints.ads.routes import inject_ads
with app.app_context():
set_setting("ads_enabled", False)
db.session.commit()
with app.test_request_context("/"):
assert inject_ads() == {"ads": {}, "show_ads": False}
with app.app_context():
set_setting("ads_enabled", True)
db.session.commit()
print("ads_enabled short-circuits inject_ads: ok")
# --- registration_open: blocks new registrations when closed ---
c.get("/auth/logout", base_url=B)
with app.app_context():
set_setting("registration_open", False)
db.session.commit()
tok = csrf(c.get("/auth/register", base_url=B).get_data(as_text=True))
r = c.post("/auth/register", base_url=B,
data={"csrf_token": tok, "display_name": "Blocked",
"email": "blocked@example.com", "password": "BlockedPass123",
"confirm": "BlockedPass123"},
headers={"Referer": B + "/auth/register"}, follow_redirects=True)
assert "closed" in r.get_data(as_text=True).lower()
with app.app_context():
assert User.query.filter_by(email="blocked@example.com").first() is None
set_setting("registration_open", True)
db.session.commit()
print("registration_open blocks new registrations: ok")
# --- maintenance_mode: 503 for non-admin, admin bypasses ---
with app.app_context():
set_setting("maintenance_mode", True)
db.session.commit()
c.get("/auth/logout", base_url=B)
assert c.get("/", base_url=B).status_code == 503
login("admin@example.com", "AdminPass123")
assert c.get("/admin", base_url=B).status_code == 200
c.get("/auth/logout", base_url=B)
with app.app_context():
set_setting("maintenance_mode", False)
db.session.commit()
assert c.get("/", base_url=B).status_code == 200
print("maintenance_mode blocks non-admins, admin bypasses: ok")
# restore target to active for cleanliness (not strictly needed, smoke ends here)
with app.app_context():
u = User.query.filter_by(email="t@example.com").first()