06/16 Phase 6 (continue)
This commit is contained in:
+21
-1
@@ -1,8 +1,13 @@
|
|||||||
"""Application factory."""
|
"""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.config import get_config
|
||||||
from app.extensions import (db, migrate, login_manager, csrf, babel, limiter)
|
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):
|
def create_app(config_object=None):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -14,6 +19,7 @@ def create_app(config_object=None):
|
|||||||
_register_blueprints(app)
|
_register_blueprints(app)
|
||||||
_register_errorhandlers(app)
|
_register_errorhandlers(app)
|
||||||
_register_context(app)
|
_register_context(app)
|
||||||
|
_register_hooks(app)
|
||||||
_register_cli(app)
|
_register_cli(app)
|
||||||
|
|
||||||
return 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):
|
def _register_cli(app):
|
||||||
@app.cli.command("expire-listings")
|
@app.cli.command("expire-listings")
|
||||||
def expire_listings_cmd():
|
def expire_listings_cmd():
|
||||||
|
|||||||
+217
-37
@@ -1,5 +1,6 @@
|
|||||||
"""Admin backend: dashboard KPIs + user management. Admin-only."""
|
"""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_login import current_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -7,9 +8,11 @@ from app.extensions import db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.listing import Listing
|
from app.models.listing import Listing
|
||||||
from app.models.plan import Plan
|
from app.models.plan import Plan
|
||||||
|
from app.models.category import Category
|
||||||
from app.models.trust import TrustEvent
|
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.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
|
||||||
@@ -23,6 +26,9 @@ admin_bp = Blueprint("admin", __name__)
|
|||||||
PER_PAGE = 25
|
PER_PAGE = 25
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dashboard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@admin_bp.route("/admin")
|
@admin_bp.route("/admin")
|
||||||
@admin_required
|
@admin_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@@ -37,6 +43,9 @@ def dashboard():
|
|||||||
return render_template("admin/dashboard.html", kpis=kpis)
|
return render_template("admin/dashboard.html", kpis=kpis)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@admin_bp.route("/admin/users")
|
@admin_bp.route("/admin/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def users():
|
def users():
|
||||||
@@ -53,7 +62,7 @@ def users():
|
|||||||
@admin_bp.route("/admin/users/<int:user_id>")
|
@admin_bp.route("/admin/users/<int:user_id>")
|
||||||
@admin_required
|
@admin_required
|
||||||
def user_detail(user_id):
|
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)
|
listings = (Listing.query.filter_by(user_id=user.id)
|
||||||
.order_by(Listing.created_at.desc()).limit(50).all())
|
.order_by(Listing.created_at.desc()).limit(50).all())
|
||||||
trust_events = (user.trust_events.order_by(TrustEvent.created_at.desc())
|
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_bp.route("/admin/users/<int:user_id>/ban", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def ban_user(user_id):
|
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):
|
if _guard_self_action(user):
|
||||||
return redirect(url_for("admin.user_detail", user_id=user.id))
|
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||||
usvc.set_status(user, UserStatus.banned, actor=current_user)
|
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_bp.route("/admin/users/<int:user_id>/suspend", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def suspend_user(user_id):
|
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):
|
if _guard_self_action(user):
|
||||||
return redirect(url_for("admin.user_detail", user_id=user.id))
|
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||||
usvc.set_status(user, UserStatus.suspended, actor=current_user)
|
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_bp.route("/admin/users/<int:user_id>/activate", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def activate_user(user_id):
|
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)
|
usvc.set_status(user, UserStatus.active, actor=current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("User activated."), "success")
|
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_bp.route("/admin/users/<int:user_id>/tier", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def set_tier(user_id):
|
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_id = request.form.get("plan_id", type=int)
|
||||||
plan = db.session.get(Plan, plan_id) if plan_id else None
|
plan = db.session.get(Plan, plan_id) if plan_id else None
|
||||||
usvc.set_tier(user, plan, actor=current_user)
|
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_bp.route("/admin/users/<int:user_id>/trust", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def trust_adjust(user_id):
|
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
|
delta = request.form.get("delta", type=int) or 0
|
||||||
event_type = request.form.get("event_type", type=str)
|
event_type = request.form.get("event_type", type=str)
|
||||||
usvc.adjust_trust(user, TrustEventType(event_type), delta, actor=current_user)
|
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))
|
return redirect(url_for("admin.user_detail", user_id=user.id))
|
||||||
|
|
||||||
|
|
||||||
# --- listing moderation ---
|
# ---------------------------------------------------------------------------
|
||||||
|
# Listing moderation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@admin_bp.route("/admin/listings")
|
@admin_bp.route("/admin/listings")
|
||||||
@admin_required
|
@admin_required
|
||||||
def listings():
|
def listings():
|
||||||
@@ -144,25 +155,10 @@ def listings():
|
|||||||
keyword_blocklist=keyword_blocklist)
|
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_bp.route("/admin/listings/<int:listing_id>/approve", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def approve_listing(listing_id):
|
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)
|
msvc.approve(listing, actor=current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("Listing approved."), "success")
|
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_bp.route("/admin/listings/<int:listing_id>/hide", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def hide_listing(listing_id):
|
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)
|
msvc.hide(listing, actor=current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("Listing hidden."), "warning")
|
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_bp.route("/admin/listings/<int:listing_id>/remove", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def remove_listing(listing_id):
|
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)
|
msvc.remove(listing, actor=current_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(_("Listing removed."), "danger")
|
flash(_("Listing removed."), "danger")
|
||||||
return redirect(url_for("admin.listings"))
|
return redirect(url_for("admin.listings"))
|
||||||
|
|
||||||
|
|
||||||
# --- reports queue ---
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reports queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@admin_bp.route("/admin/reports")
|
@admin_bp.route("/admin/reports")
|
||||||
@admin_required
|
@admin_required
|
||||||
def reports():
|
def reports():
|
||||||
candidates = (Report.query.join(Listing, Report.listing_id == Listing.id)
|
dismissed_subq = (
|
||||||
.filter(Listing.status == ListingStatus.flagged)
|
db.session.query(AuditLog.target_id)
|
||||||
.order_by(Report.created_at.desc()).all())
|
.filter(AuditLog.target_type == "report",
|
||||||
dismissed_ids = {a.target_id for a in
|
AuditLog.action == "report.dismissed")
|
||||||
AuditLog.query.filter_by(target_type="report", action="report.dismissed").all()}
|
.scalar_subquery()
|
||||||
open_reports = [r for r in candidates if r.id not in dismissed_ids]
|
)
|
||||||
|
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)
|
return render_template("admin/reports.html", reports=open_reports)
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/reports/<int:report_id>/dismiss", methods=["POST"])
|
@admin_bp.route("/admin/reports/<int:report_id>/dismiss", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def dismiss_report(report_id):
|
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,
|
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()
|
db.session.commit()
|
||||||
flash(_("Report dismissed."), "info")
|
flash(_("Report dismissed."), "info")
|
||||||
return redirect(url_for("admin.reports"))
|
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_bp.route("/admin/reports/<int:report_id>/escalate", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def escalate_report(report_id):
|
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,
|
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()
|
db.session.commit()
|
||||||
flash(_("Report escalated."), "warning")
|
flash(_("Report escalated."), "warning")
|
||||||
return redirect(url_for("admin.reports"))
|
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.models.ads import Ad
|
||||||
from app.services.ads import (get_ad, record_impression, record_click,
|
from app.services.ads import (get_ad, record_impression, record_click,
|
||||||
active_sponsors)
|
active_sponsors)
|
||||||
|
from app.services.settings import get_setting
|
||||||
|
|
||||||
ads_bp = Blueprint("ads", __name__)
|
ads_bp = Blueprint("ads", __name__)
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ def inject_ads():
|
|||||||
Returns ad slots for use in templates.
|
Returns ad slots for use in templates.
|
||||||
Ads suppressed for subscribers (ad_free plan limit).
|
Ads suppressed for subscribers (ad_free plan limit).
|
||||||
"""
|
"""
|
||||||
|
if not get_setting("ads_enabled", True):
|
||||||
|
return {"ads": {}, "show_ads": False}
|
||||||
|
|
||||||
show_ads = True
|
show_ads = True
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
plan = current_user.tier
|
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.email import send_email
|
||||||
from app.services.turnstile import verify_turnstile, turnstile_enabled
|
from app.services.turnstile import verify_turnstile, turnstile_enabled
|
||||||
from app.services.trust import record_event
|
from app.services.trust import record_event
|
||||||
|
from app.services.settings import get_setting
|
||||||
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
|
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
|
||||||
ResetRequestForm, ResetForm)
|
ResetRequestForm, ResetForm)
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ def register():
|
|||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
form = RegisterForm()
|
form = RegisterForm()
|
||||||
if form.validate_on_submit():
|
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():
|
if not verify_turnstile():
|
||||||
flash(_("Captcha verification failed."), "danger")
|
flash(_("Captcha verification failed."), "danger")
|
||||||
return render_template("auth/register.html", form=form,
|
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.
|
moderators can spot scraping attempts.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from app.models.enums import TrustTier
|
from app.models.enums import TrustTier
|
||||||
|
from app.services.settings import get_setting
|
||||||
|
|
||||||
# patterns
|
# patterns
|
||||||
_PHONE_RE = re.compile(
|
_PHONE_RE = re.compile(
|
||||||
@@ -25,8 +27,14 @@ def contact_revealed(user) -> bool:
|
|||||||
"""True when this user's contact info may be shown unmasked."""
|
"""True when this user's contact info may be shown unmasked."""
|
||||||
if user is None:
|
if user is None:
|
||||||
return False
|
return False
|
||||||
return (user.email_verified and
|
if not (user.email_verified and
|
||||||
user.trust_tier in (TrustTier.trusted, TrustTier.verified))
|
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:
|
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.models.messaging import Conversation, Message
|
||||||
from app.services.contact import contact_density
|
from app.services.contact import contact_density
|
||||||
from app.services.email import send_email
|
from app.services.email import send_email
|
||||||
|
from app.services.settings import get_setting
|
||||||
# Bodies with ≥ 3 contact signals get auto-flagged
|
|
||||||
_FLAG_THRESHOLD = 3
|
|
||||||
|
|
||||||
|
|
||||||
class MessagingError(ValueError):
|
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):
|
if sender.id not in (conversation.buyer_id, conversation.seller_id):
|
||||||
raise MessagingError("not a participant")
|
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(
|
msg = Message(
|
||||||
conversation_id=conversation.id,
|
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)
|
reason=reason, note=(note or "").strip() or None)
|
||||||
db.session.add(report)
|
db.session.add(report)
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.flush()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
raise ReportError("you have already reported this listing")
|
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.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.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.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>
|
</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 %}
|
{% block content %}
|
||||||
{% include "admin/_nav.html" %}
|
{% include "admin/_nav.html" %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{{ _('Moderation settings') }}</h2>
|
<p class="muted">
|
||||||
<form method="post" action="{{ url_for('admin.listings_settings') }}" class="settings-panel">
|
{{ _('Auto-flag threshold: %(n)s distinct reports · Blocklist: %(k)s keywords', n=flag_threshold, k=keyword_blocklist|length) }}
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
· <a href="{{ url_for('admin.settings') }}">{{ _('Edit in Settings') }}</a>
|
||||||
<div class="field">
|
</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<h2>{{ _('Flag queue') }}</h2>
|
<h2>{{ _('Flag queue') }}</h2>
|
||||||
{% if not pagination.items %}<p class="muted">{{ _('No flagged listings.') }}</p>{% endif %}
|
{% 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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
@@ -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 -
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -657,17 +657,24 @@ def _phase6(app):
|
|||||||
|
|
||||||
# non-admin gets 403
|
# non-admin gets 403
|
||||||
login("t@example.com", "NewPass456")
|
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
|
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)
|
||||||
|
|
||||||
# admin gets 200 on dashboard/users/user_detail/listings/reports
|
# admin gets 200 on all admin pages
|
||||||
login("admin@example.com", "AdminPass123")
|
login("admin@example.com", "AdminPass123")
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
target_id = User.query.filter_by(email="t@example.com").first().id
|
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}",
|
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
|
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}")
|
||||||
@@ -715,6 +722,76 @@ def _phase6(app):
|
|||||||
assert "HTTP Report Test" not in r2.get_data(as_text=True)
|
assert "HTTP Report Test" not in r2.get_data(as_text=True)
|
||||||
print("report dismiss hides from open queue: ok")
|
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)
|
# restore target to active for cleanliness (not strictly needed, smoke ends here)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
u = User.query.filter_by(email="t@example.com").first()
|
u = User.query.filter_by(email="t@example.com").first()
|
||||||
|
|||||||
Reference in New Issue
Block a user