From 68a842d6b69408ddb6fb95395095a53981b0ef9f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 17 Jun 2026 17:06:43 -0400 Subject: [PATCH] 06/16 Phase 6 (continue) --- app/__init__.py | 22 +- app/blueprints/admin/routes.py | 254 +++++++++++++++++++---- app/blueprints/ads/routes.py | 4 + app/blueprints/auth/routes.py | 5 + app/services/contact.py | 12 +- app/services/messaging.py | 7 +- app/services/reports.py | 2 +- app/templates/admin/_nav.html | 5 + app/templates/admin/audit.html | 48 +++++ app/templates/admin/categories.html | 38 ++++ app/templates/admin/category_schema.html | 18 ++ app/templates/admin/listings.html | 17 +- app/templates/admin/plan_edit.html | 30 +++ app/templates/admin/plans.html | 28 +++ app/templates/admin/settings.html | 43 ++++ app/templates/admin/transactions.html | 61 ++++++ app/templates/errors/maintenance.html | 3 + cookies_admin.txt | 5 + cookies_anon.txt | 5 + dev_check3.db | Bin 0 -> 335872 bytes flask_dev3.log | 15 ++ home_anon.html | 61 ++++++ reg_attempt.html | 98 +++++++++ settings_page.html | 109 ++++++++++ settings_page2.html | 115 ++++++++++ tests/test_smoke.py | 83 +++++++- 26 files changed, 1027 insertions(+), 61 deletions(-) create mode 100644 app/templates/admin/audit.html create mode 100644 app/templates/admin/categories.html create mode 100644 app/templates/admin/category_schema.html create mode 100644 app/templates/admin/plan_edit.html create mode 100644 app/templates/admin/plans.html create mode 100644 app/templates/admin/settings.html create mode 100644 app/templates/admin/transactions.html create mode 100644 app/templates/errors/maintenance.html create mode 100644 cookies_admin.txt create mode 100644 cookies_anon.txt create mode 100644 dev_check3.db create mode 100644 flask_dev3.log create mode 100644 home_anon.html create mode 100644 reg_attempt.html create mode 100644 settings_page.html create mode 100644 settings_page2.html diff --git a/app/__init__.py b/app/__init__.py index e00f91b..386fe2a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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(): diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py index 1a424d4..c0c1f57 100644 --- a/app/blueprints/admin/routes.py +++ b/app/blueprints/admin/routes.py @@ -1,5 +1,6 @@ """Admin backend: dashboard KPIs + user management. Admin-only.""" -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/") @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//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//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//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//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//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//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//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//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//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//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//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//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/", 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) diff --git a/app/blueprints/ads/routes.py b/app/blueprints/ads/routes.py index 06d3c04..b6902e7 100644 --- a/app/blueprints/ads/routes.py +++ b/app/blueprints/ads/routes.py @@ -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 diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index ddc9bf9..a1608fa 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -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, diff --git a/app/services/contact.py b/app/services/contact.py index b7cc625..dde07c3 100644 --- a/app/services/contact.py +++ b/app/services/contact.py @@ -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: diff --git a/app/services/messaging.py b/app/services/messaging.py index d6ad808..7c6992f 100644 --- a/app/services/messaging.py +++ b/app/services/messaging.py @@ -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, diff --git a/app/services/reports.py b/app/services/reports.py index d2cd181..d560a2b 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -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") diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index ce91b41..2c5935b 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -3,4 +3,9 @@ {{ _('Users') }} {{ _('Listings') }} {{ _('Reports') }} + {{ _('Categories') }} + {{ _('Plans') }} + {{ _('Transactions') }} + {{ _('Audit log') }} + {{ _('Settings') }} diff --git a/app/templates/admin/audit.html b/app/templates/admin/audit.html new file mode 100644 index 0000000..1f9701c --- /dev/null +++ b/app/templates/admin/audit.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Audit log') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Audit log') }}

+ +
+
+ + +
+ + {% if filters.get('action') %}{{ _('Clear') }}{% endif %} +
+ + + + + + + + + + {% for entry in pagination.items %} + + + + + + + + {% else %} + + {% endfor %} +
{{ _('When') }}{{ _('Actor') }}{{ _('Action') }}{{ _('Target') }}{{ _('Meta') }}
{{ entry.created_at.strftime('%Y-%m-%d %H:%M') }} + {% if entry.actor %} + {{ entry.actor.display_name }} + {% else %}{% endif %} + {{ entry.action }}{{ entry.target_type }}{% if entry.target_id %}:{{ entry.target_id }}{% endif %}{{ entry.meta or '' }}
{{ _('No entries.') }}
+ +
+ {% if pagination.has_prev %}← {{ _('Prev') }}{% endif %} + {{ _('Page %(p)s of %(t)s', p=pagination.page, t=pagination.pages) }} + {% if pagination.has_next %}{{ _('Next') }} →{% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/categories.html b/app/templates/admin/categories.html new file mode 100644 index 0000000..fe459b3 --- /dev/null +++ b/app/templates/admin/categories.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Categories') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Categories') }}

+ + {% for cat in categories %} + + + + + + + {% for sub in cat.children|sort(attribute='sort_order') %} + + + + + + + {% endfor %} + {% endfor %} +
{{ cat.name }} {{ cat.slug }}{{ _('active') if cat.is_active else _('inactive') }}{{ cat.children|length }} {{ _('subcategories') }} + {{ _('Field schema') }} +
+ + +
+
{{ sub.name }} {{ sub.slug }}{{ _('active') if sub.is_active else _('inactive') }} + {{ _('Field schema') }} +
+ + +
+
+
+{% endblock %} diff --git a/app/templates/admin/category_schema.html b/app/templates/admin/category_schema.html new file mode 100644 index 0000000..1c8fec3 --- /dev/null +++ b/app/templates/admin/category_schema.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · %(name)s schema', name=cat.name) }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Field schema: %(name)s', name=cat.name) }}

+

{{ _('Edit the JSON field schema for this category. Supported types: text, number, select, bool. Add "hot": true to denormalize onto an indexed column.') }}

+ {% if error %}

{{ error }}

{% endif %} +
+ +
+ +
+ + {{ _('Cancel') }} +
+
+{% endblock %} diff --git a/app/templates/admin/listings.html b/app/templates/admin/listings.html index 28268db..e1a5e72 100644 --- a/app/templates/admin/listings.html +++ b/app/templates/admin/listings.html @@ -3,19 +3,10 @@ {% block content %} {% include "admin/_nav.html" %}
-

{{ _('Moderation settings') }}

-
- -
- - -
-
- - -
- -
+

+ {{ _('Auto-flag threshold: %(n)s distinct reports · Blocklist: %(k)s keywords', n=flag_threshold, k=keyword_blocklist|length) }} + · {{ _('Edit in Settings') }} +

{{ _('Flag queue') }}

{% if not pagination.items %}

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

{% endif %} diff --git a/app/templates/admin/plan_edit.html b/app/templates/admin/plan_edit.html new file mode 100644 index 0000000..7d62649 --- /dev/null +++ b/app/templates/admin/plan_edit.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Edit plan: %(slug)s', slug=plan.slug) }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Edit plan: %(name)s', name=plan.name) }}

+ {% if error %}

{{ error }}

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

{{ _('Plans') }}

+ + + + + + + + + + {% for plan in plans %} + + + + + + + + + {% endfor %} +
{{ _('Slug') }}{{ _('Name') }}{{ _('Price / mo') }}{{ _('Stripe price ID') }}{{ _('Active') }}
{{ plan.slug }}{{ plan.name }}${{ '%.2f'|format(plan.price_monthly_cents / 100) }}{{ plan.stripe_price_id or '—' }}{{ _('yes') if plan.is_active else _('no') }}{{ _('Edit') }}
+
+{% endblock %} diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..b7961ab --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Settings') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Settings') }}

+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+{% endblock %} diff --git a/app/templates/admin/transactions.html b/app/templates/admin/transactions.html new file mode 100644 index 0000000..607db5b --- /dev/null +++ b/app/templates/admin/transactions.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Transactions') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Transactions') }}

+

{{ _('All-time revenue (succeeded): $%(amount)s', amount='%.2f'|format(total_cents / 100)) }}

+ +
+
+ + +
+
+ + +
+ + {% if filters.get('type') or filters.get('status') %}{{ _('Clear') }}{% endif %} +
+ + + + + + + + + + + {% for txn in pagination.items %} + + + + + + + + + {% else %} + + {% endfor %} +
{{ _('Date') }}{{ _('User') }}{{ _('Type') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Stripe ID') }}
{{ txn.created_at.strftime('%Y-%m-%d') }}{{ txn.user.display_name }}{{ txn.type }}${{ '%.2f'|format(txn.amount_cents / 100) }}{{ txn.status }}{{ txn.stripe_object_id or '—' }}
{{ _('No transactions.') }}
+ +
+ {% if pagination.has_prev %}← {{ _('Prev') }}{% endif %} + {{ _('Page %(p)s of %(t)s', p=pagination.page, t=pagination.pages) }} + {% if pagination.has_next %}{{ _('Next') }} →{% endif %} +
+
+{% endblock %} diff --git a/app/templates/errors/maintenance.html b/app/templates/errors/maintenance.html new file mode 100644 index 0000000..adac9c3 --- /dev/null +++ b/app/templates/errors/maintenance.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}{{ _('Maintenance') }}{% endblock %} +{% block content %}

{{ _('Down for maintenance') }}

{{ _('Check back soon.') }}

{% endblock %} diff --git a/cookies_admin.txt b/cookies_admin.txt new file mode 100644 index 0000000..4d25cb1 --- /dev/null +++ b/cookies_admin.txt @@ -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 diff --git a/cookies_anon.txt b/cookies_anon.txt new file mode 100644 index 0000000..202e0ae --- /dev/null +++ b/cookies_anon.txt @@ -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 diff --git a/dev_check3.db b/dev_check3.db new file mode 100644 index 0000000000000000000000000000000000000000..9c94234d069a763fe1e51c74fc8df0b3bd636225 GIT binary patch literal 335872 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 MTsGM!Go;D>e^|Ac8UO$Q literal 0 HcmV?d00001 diff --git a/flask_dev3.log b/flask_dev3.log new file mode 100644 index 0000000..2e94f2c --- /dev/null +++ b/flask_dev3.log @@ -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 - diff --git a/home_anon.html b/home_anon.html new file mode 100644 index 0000000..4286fe8 --- /dev/null +++ b/home_anon.html @@ -0,0 +1,61 @@ + + + + + + Classifieds — Home + + + + + + +
+ + + + +
+

Find what you need. Post what you offer.

+

Buy, sell, request, hire, and connect across your community.

+ Browse listings + + Get started + +
+ +
+ +
+ + +
© 2025 Classifieds · Sponsors
+
+ + \ No newline at end of file diff --git a/reg_attempt.html b/reg_attempt.html new file mode 100644 index 0000000..466779f --- /dev/null +++ b/reg_attempt.html @@ -0,0 +1,98 @@ + + + + + + Register + + + + + + +
+ + +
+ +
Registration is currently closed.
+ +
+ + + +
+

Create account

+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + + + + +
+

Already have an account? Sign in

+
+ +
+ +
+ + +
© 2025 Classifieds · Sponsors
+
+ + \ No newline at end of file diff --git a/settings_page.html b/settings_page.html new file mode 100644 index 0000000..a7be2b9 --- /dev/null +++ b/settings_page.html @@ -0,0 +1,109 @@ + + + + + + Admin · Settings + + + + + + +
+ + + + + +
+

Settings

+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ +
+ + +
© 2025 Classifieds · Sponsors
+
+ + \ No newline at end of file diff --git a/settings_page2.html b/settings_page2.html new file mode 100644 index 0000000..43f5632 --- /dev/null +++ b/settings_page2.html @@ -0,0 +1,115 @@ + + + + + + Admin · Settings + + + + + + +
+ + +
+ +
Settings updated.
+ +
+ + + + +
+

Settings

+
+ + +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ + + + \ No newline at end of file diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5272f36..e038d0a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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()