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 0000000..9c94234 Binary files /dev/null and b/dev_check3.db differ 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()