diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py index 1c4da26..1a424d4 100644 --- a/app/blueprints/admin/routes.py +++ b/app/blueprints/admin/routes.py @@ -9,10 +9,14 @@ from app.models.listing import Listing from app.models.plan import Plan from app.models.trust import TrustEvent from app.models.audit import AuditLog -from app.models.enums import UserStatus, TrustEventType +from app.models.report import Report +from app.models.enums import UserStatus, TrustEventType, ListingStatus from app.utils import admin_required from app.services import admin_users as usvc from app.services import admin_dashboard as dash +from app.services import moderation as msvc +from app.services.settings import get_setting, set_setting +from app.services import audit admin_bp = Blueprint("admin", __name__) @@ -125,3 +129,96 @@ def trust_adjust(user_id): db.session.commit() flash(_("Trust adjusted."), "success") return redirect(url_for("admin.user_detail", user_id=user.id)) + + +# --- listing moderation --- +@admin_bp.route("/admin/listings") +@admin_required +def listings(): + page = request.args.get("page", 1, type=int) + pagination = msvc.flag_queue(page=page, per_page=PER_PAGE) + flag_threshold = get_setting("flag_threshold", 5) + keyword_blocklist = get_setting("keyword_blocklist", []) + return render_template("admin/listings.html", pagination=pagination, + flag_threshold=flag_threshold, + 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) + msvc.approve(listing, actor=current_user) + db.session.commit() + flash(_("Listing approved."), "success") + return redirect(url_for("admin.listings")) + + +@admin_bp.route("/admin/listings//hide", methods=["POST"]) +@admin_required +def hide_listing(listing_id): + listing = Listing.query.get_or_404(listing_id) + msvc.hide(listing, actor=current_user) + db.session.commit() + flash(_("Listing hidden."), "warning") + return redirect(url_for("admin.listings")) + + +@admin_bp.route("/admin/listings//remove", methods=["POST"]) +@admin_required +def remove_listing(listing_id): + listing = Listing.query.get_or_404(listing_id) + msvc.remove(listing, actor=current_user) + db.session.commit() + flash(_("Listing removed."), "danger") + return redirect(url_for("admin.listings")) + + +# --- 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] + 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) + audit.log_action(current_user, "report.dismissed", "report", report.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")) + + +@admin_bp.route("/admin/reports//escalate", methods=["POST"]) +@admin_required +def escalate_report(report_id): + report = Report.query.get_or_404(report_id) + audit.log_action(current_user, "report.escalated", "report", report.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")) diff --git a/app/blueprints/listings/routes.py b/app/blueprints/listings/routes.py index 7261c64..0a0ccf5 100644 --- a/app/blueprints/listings/routes.py +++ b/app/blueprints/listings/routes.py @@ -9,10 +9,11 @@ from flask_babel import gettext as _ from app.extensions import db, limiter from app.models.category import Category from app.models.listing import Listing, ListingImage -from app.models.enums import ListingStatus +from app.models.enums import ListingStatus, ReportReason from app.services import listings as svc from app.services.geo import geocode_zip from app.services.images import process_upload, delete_image_files, ImageError +from app.services import reports as rsvc from app.blueprints.listings.forms import ListingForm, ImageUploadForm listings_bp = Blueprint("listings", __name__) @@ -220,6 +221,28 @@ def mark_sold(listing_id): return redirect(url_for("listings.detail", listing_id=listing.id)) +# --- report --- +@listings_bp.route("/listings//report", methods=["POST"]) +@login_required +@limiter.limit("20 per hour", methods=["POST"]) +def report(listing_id): + listing = Listing.query.get_or_404(listing_id) + reason_raw = request.form.get("reason", type=str) + note = request.form.get("note", type=str) + try: + reason = ReportReason(reason_raw) + except ValueError: + flash(_("Invalid report reason."), "danger") + return redirect(url_for("listings.detail", listing_id=listing.id)) + try: + rsvc.create_report(listing, current_user, reason, note=note) + except rsvc.ReportError as e: + flash(str(e), "warning") + else: + flash(_("Thanks — this listing has been reported for review."), "success") + return redirect(url_for("listings.detail", listing_id=listing.id)) + + # --- my listings --- @listings_bp.route("/my/listings") @login_required diff --git a/app/models/__init__.py b/app/models/__init__.py index 9bda8fa..8370b74 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.listing import Listing, ListingImage from app.models.geo import ZipGeo, Metro from app.models.messaging import Conversation, Message from app.models.favorite import Favorite +from app.models.report import Report from app.models.payments import Subscription, Transaction, Boost from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.audit import AuditLog @@ -14,5 +15,5 @@ from app.models.setting import Setting __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message", - "Favorite", "Subscription", "Transaction", "Boost", + "Favorite", "Report", "Subscription", "Transaction", "Boost", "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"] diff --git a/app/models/enums.py b/app/models/enums.py index 48f4b0f..6547151 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -42,3 +42,12 @@ class Lang(str, enum.Enum): en = "en" vi = "vi" es = "es" + + +class ReportReason(str, enum.Enum): + spam = "spam" + scam = "scam" + offensive = "offensive" + duplicate = "duplicate" + miscategorized = "miscategorized" + other = "other" diff --git a/app/models/report.py b/app/models/report.py new file mode 100644 index 0000000..798902d --- /dev/null +++ b/app/models/report.py @@ -0,0 +1,28 @@ +"""User-submitted reports against listings (spam/abuse moderation).""" +from datetime import datetime +from app.extensions import db +from app.models.enums import ReportReason + + +class Report(db.Model): + __tablename__ = "reports" + + id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + primary_key=True, autoincrement=True) + listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("listings.id"), nullable=False, index=True) + reporter_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), + db.ForeignKey("users.id"), nullable=False, index=True) + reason = db.Column(db.Enum(ReportReason), nullable=False) + note = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + listing = db.relationship("Listing", backref=db.backref("reports", lazy="dynamic")) + reporter = db.relationship("User", backref=db.backref("reports_filed", lazy="dynamic")) + + __table_args__ = ( + db.UniqueConstraint("reporter_id", "listing_id", name="uq_report_reporter_listing"), + ) + + def __repr__(self): + return f"" diff --git a/app/services/admin_dashboard.py b/app/services/admin_dashboard.py index 7b279d7..85d62c0 100644 --- a/app/services/admin_dashboard.py +++ b/app/services/admin_dashboard.py @@ -41,4 +41,5 @@ def revenue_30d_cents(): def flag_queue_depth(): - return Listing.query.filter(Listing.flag_count > 0).count() + from app.services.moderation import QUEUE_FILTER + return Listing.query.filter(QUEUE_FILTER).count() diff --git a/app/services/listings.py b/app/services/listings.py index 6d512ba..0a338e0 100644 --- a/app/services/listings.py +++ b/app/services/listings.py @@ -8,6 +8,7 @@ from app.models.enums import ListingStatus, Lang from app.utils.text import normalize from app.services.geo import geocode_zip, bounding_box, haversine_mi from app.services.field_schema import validate_attributes, hot_values +from app.services.settings import get_setting class ListingError(ValueError): @@ -48,6 +49,15 @@ def _life_days(user): return _limit(user, "listing_life_days", 14) +def _blocklist_hit(title, body): + """Accent-insensitive check against the admin-configured keyword blocklist.""" + blocklist = get_setting("keyword_blocklist", []) + if not blocklist: + return False + hay = normalize(f"{title} {body}") + return any(normalize(term) in hay for term in blocklist if term) + + # --- create / update --- def create_listing(user, category, *, title, body, lang, price_cents, zip_code, raw_attributes): @@ -71,6 +81,8 @@ def create_listing(user, category, *, title, body, lang, price_cents, expires_at=datetime.utcnow() + timedelta(days=_life_days(user)), **hot_values(cleaned), ) + if _blocklist_hit(title, body): + listing.status = ListingStatus.flagged _apply_location(listing, zip_code) db.session.add(listing) db.session.commit() @@ -91,6 +103,8 @@ def update_listing(listing, category, *, title, body, lang, price_cents, listing.attributes = cleaned for col, val in hot_values(cleaned).items(): setattr(listing, col, val) + if listing.status == ListingStatus.active and _blocklist_hit(title, body): + listing.status = ListingStatus.flagged _apply_location(listing, zip_code) db.session.commit() return listing diff --git a/app/services/moderation.py b/app/services/moderation.py new file mode 100644 index 0000000..4c77725 --- /dev/null +++ b/app/services/moderation.py @@ -0,0 +1,43 @@ +"""Admin listing moderation: flag queue + approve/hide/remove actions. + +Each action mutates the listing + calls audit.log_action; caller commits +(mirrors services/admin_users.py). +""" +from app.extensions import db +from app.models.listing import Listing +from app.models.enums import ListingStatus +from app.services import audit + +# A listing is in the moderation queue if it's already flagged, or it's +# still active but has accumulated reports below the auto-flag threshold. +QUEUE_FILTER = db.or_( + Listing.status == ListingStatus.flagged, + db.and_(Listing.status == ListingStatus.active, Listing.flag_count > 0), +) + + +def flag_queue(*, page=1, per_page=25): + return (Listing.query.filter(QUEUE_FILTER) + .order_by(Listing.flag_count.desc(), Listing.updated_at.desc()) + .paginate(page=page, per_page=per_page, error_out=False)) + + +def approve(listing, *, actor): + """Clear flags, restore to active. Caller commits.""" + old = listing.flag_count + listing.status = ListingStatus.active + listing.flag_count = 0 + audit.log_action(actor, "listing.approved", "listing", listing.id, + meta={"cleared_flags": old}) + + +def hide(listing, *, actor): + """Manually flag for review without removing. Caller commits.""" + listing.status = ListingStatus.flagged + audit.log_action(actor, "listing.hidden", "listing", listing.id) + + +def remove(listing, *, actor): + """Permanently remove from the marketplace (status flip, not a hard delete). Caller commits.""" + listing.status = ListingStatus.removed + audit.log_action(actor, "listing.removed", "listing", listing.id) diff --git a/app/services/reports.py b/app/services/reports.py new file mode 100644 index 0000000..d2cd181 --- /dev/null +++ b/app/services/reports.py @@ -0,0 +1,32 @@ +"""User-submitted listing reports + flag-count threshold auto-flag.""" +from sqlalchemy.exc import IntegrityError +from app.extensions import db +from app.models.report import Report +from app.models.enums import ListingStatus, ReportReason +from app.services.settings import get_setting + + +class ReportError(ValueError): + pass + + +def create_report(listing, reporter, reason: ReportReason, note=None): + """Record a report, bump flag_count, auto-flag at threshold. Commits.""" + if listing.user_id == reporter.id: + raise ReportError("cannot report your own listing") + + report = Report(listing_id=listing.id, reporter_id=reporter.id, + reason=reason, note=(note or "").strip() or None) + db.session.add(report) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + raise ReportError("you have already reported this listing") + + listing.flag_count = (listing.flag_count or 0) + 1 + threshold = get_setting("flag_threshold", 5) + if listing.flag_count >= threshold and listing.status == ListingStatus.active: + listing.status = ListingStatus.flagged + db.session.commit() + return report diff --git a/app/static/style.css b/app/static/style.css index f0d89f6..36e872e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -198,3 +198,6 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} .kpi-value{font-size:26px;font-weight:800} .admin-filter-row{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap} .admin-filter-row .field{margin-bottom:0} +.report-form{display:flex;flex-direction:column;gap:6px;margin-top:10px} +.settings-panel{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)} +.settings-panel textarea.input{min-height:80px;font-family:inherit} diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 345aab2..ce91b41 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,4 +1,6 @@ diff --git a/app/templates/admin/listings.html b/app/templates/admin/listings.html new file mode 100644 index 0000000..28268db --- /dev/null +++ b/app/templates/admin/listings.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Listings') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Moderation settings') }}

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

{{ _('Flag queue') }}

+ {% if not pagination.items %}

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

{% endif %} + + {% for l in pagination.items %} + + + + + + + + {% endfor %} +
{{ l.title }}{{ l.flag_count }} {{ _('flags') }}{{ l.status.value }}{{ l.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+ + +
+
+ + +
+
+ +
+ {% if pagination.has_prev %}← {{ _('Prev') }}{% endif %} + {% if pagination.has_next %}{{ _('Next') }} →{% endif %} +
+
+{% endblock %} diff --git a/app/templates/admin/reports.html b/app/templates/admin/reports.html new file mode 100644 index 0000000..703a310 --- /dev/null +++ b/app/templates/admin/reports.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ _('Admin · Reports') }}{% endblock %} +{% block content %} +{% include "admin/_nav.html" %} +
+

{{ _('Open reports') }}

+ {% if not reports %}

{{ _('No open reports.') }}

{% endif %} + + {% for r in reports %} + + + + + + + + + {% endfor %} +
{{ r.listing.title }}{{ r.reporter.display_name }}{{ r.reason.value }}{{ r.note or '—' }}{{ r.created_at.strftime('%Y-%m-%d') }} +
+ + +
+
+ + +
+
+
+{% endblock %} diff --git a/app/templates/listings/detail.html b/app/templates/listings/detail.html index 4915db4..00699c9 100644 --- a/app/templates/listings/detail.html +++ b/app/templates/listings/detail.html @@ -62,6 +62,17 @@ +
+ + + + +
{% else %} {{ _('Sign in to contact') }} diff --git a/migrations/versions/482a73a8c5bb_reports_table.py b/migrations/versions/482a73a8c5bb_reports_table.py new file mode 100644 index 0000000..f357919 --- /dev/null +++ b/migrations/versions/482a73a8c5bb_reports_table.py @@ -0,0 +1,47 @@ +"""reports table + +Revision ID: 482a73a8c5bb +Revises: 74911acb8bfa +Create Date: 2026-06-16 12:07:15.936172 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '482a73a8c5bb' +down_revision = '74911acb8bfa' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('reports', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False), + sa.Column('listing_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False), + sa.Column('reporter_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False), + sa.Column('reason', sa.Enum('spam', 'scam', 'offensive', 'duplicate', 'miscategorized', 'other', name='reportreason'), nullable=False), + sa.Column('note', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['listing_id'], ['listings.id'], ), + sa.ForeignKeyConstraint(['reporter_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('reporter_id', 'listing_id', name='uq_report_reporter_listing') + ) + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_reports_listing_id'), ['listing_id'], unique=False) + batch_op.create_index(batch_op.f('ix_reports_reporter_id'), ['reporter_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_reports_reporter_id')) + batch_op.drop_index(batch_op.f('ix_reports_listing_id')) + + op.drop_table('reports') + # ### end Alembic commands ### diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7cd70c8..5272f36 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -489,6 +489,12 @@ def _phase6(app): from app.services import admin_users as usvc from app.services import admin_dashboard as dash from app.services.settings import get_setting, set_setting + from app.models.report import Report + from app.models.category import Category + from app.models.enums import ListingStatus, ReportReason + from app.services import listings as lsvc + from app.services import reports as rsvc + from app.services import moderation as msvc with app.app_context(): admin = User(email="admin@example.com", display_name="Admin", @@ -547,6 +553,95 @@ def _phase6(app): usvc.set_status(target, UserStatus.active, actor=admin) db.session.commit() + # --- keyword blocklist: auto-flag on create --- + for_sale = Category.query.filter_by(slug="for-sale").first() + set_setting("keyword_blocklist", ["viagra"]) + db.session.commit() + spammy = lsvc.create_listing(target, for_sale, title="Cheap meds", + body="Buy real viagra online cheap", + lang="en", price_cents=500, zip_code="92683", + raw_attributes={"condition": "new"}) + assert spammy.status == ListingStatus.flagged + print("keyword blocklist auto-flags on create: ok") + + # --- keyword blocklist: auto-flag on update --- + clean = lsvc.create_listing(target, for_sale, title="Clean Listing", + body="A perfectly normal item for sale.", + lang="en", price_cents=500, zip_code="92683", + raw_attributes={"condition": "new"}) + assert clean.status == ListingStatus.active + lsvc.update_listing(clean, for_sale, title="Clean Listing", + body="Now selling viagra too", lang="en", + price_cents=500, zip_code="92683", + raw_attributes={"condition": "new"}) + assert clean.status == ListingStatus.flagged + print("keyword blocklist auto-flags on update: ok") + set_setting("keyword_blocklist", []) + db.session.commit() + + # --- reports: self-report blocked --- + report_target = lsvc.create_listing(target, for_sale, title="Reportable Item", + body="Nothing wrong with this one.", + lang="en", price_cents=1000, zip_code="92683", + raw_attributes={"condition": "new"}) + try: + rsvc.create_report(report_target, target, ReportReason.spam) + assert False, "expected self-report to be blocked" + except rsvc.ReportError as e: + assert "own listing" in str(e) + print("self-report blocked: ok") + + # --- reports: duplicate report blocked --- + buyer = User.query.filter_by(email="buyer@example.com").first() + rsvc.create_report(report_target, buyer, ReportReason.spam, note="looks fake") + try: + rsvc.create_report(report_target, buyer, ReportReason.scam) + assert False, "expected duplicate report to be blocked" + except rsvc.ReportError as e: + assert "already reported" in str(e) + print("duplicate report blocked: ok") + + # --- reports: threshold auto-flags --- + set_setting("flag_threshold", 2) + db.session.commit() + assert report_target.flag_count == 1 + assert report_target.status == ListingStatus.active + rsvc.create_report(report_target, admin, ReportReason.scam) + assert report_target.flag_count == 2 + assert report_target.status == ListingStatus.flagged + print(f"report threshold auto-flag: ok (flag_count={report_target.flag_count})") + + # --- moderation actions + audit log --- + msvc.approve(report_target, actor=admin) + db.session.commit() + assert report_target.status == ListingStatus.active and report_target.flag_count == 0 + assert AuditLog.query.filter_by(action="listing.approved", + target_id=report_target.id).count() == 1 + print("moderation approve + audit log: ok") + + msvc.hide(spammy, actor=admin) + db.session.commit() + assert spammy.status == ListingStatus.flagged + assert AuditLog.query.filter_by(action="listing.hidden", + target_id=spammy.id).count() == 1 + print("moderation hide + audit log: ok") + + msvc.remove(clean, actor=admin) + db.session.commit() + assert clean.status == ListingStatus.removed + assert AuditLog.query.filter_by(action="listing.removed", + target_id=clean.id).count() == 1 + print("moderation remove + audit log: ok") + + # lower threshold to 1 so a single HTTP report flips status for the next check + set_setting("flag_threshold", 1) + http_listing = lsvc.create_listing(target, for_sale, title="HTTP Report Test", + body="Totally fine listing.", lang="en", + price_cents=750, zip_code="92683", + raw_attributes={"condition": "new"}) + http_listing_id = http_listing.id + db.session.commit() + # --- route checks --- c = app.test_client(); B = "https://localhost" @@ -562,15 +657,17 @@ def _phase6(app): # non-admin gets 403 login("t@example.com", "NewPass456") - assert c.get("/admin", base_url=B).status_code == 403 - print("non-admin /admin -> 403: ok") + for path in ("/admin", "/admin/listings", "/admin/reports"): + 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 + # admin gets 200 on dashboard/users/user_detail/listings/reports login("admin@example.com", "AdminPass123") with app.app_context(): target_id = User.query.filter_by(email="t@example.com").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"): code = c.get(path, base_url=B).status_code assert code == 200, f"{path} -> {code}" print(f"{code} {path}") @@ -590,6 +687,34 @@ def _phase6(app): assert "suspended" in r.get_data(as_text=True).lower() print("banned user login rejected: ok") + # buyer reports a listing via the real HTTP route + c.get("/auth/logout", base_url=B) + login("buyer@example.com", "BuyerPass123") + r = c.get(f"/listings/{http_listing_id}", base_url=B) + tok = csrf(r.get_data(as_text=True)) + c.post(f"/listings/{http_listing_id}/report", base_url=B, + data={"csrf_token": tok, "reason": "spam", "note": "test report"}, + headers={"Referer": B + f"/listings/{http_listing_id}"}, follow_redirects=True) + with app.app_context(): + rep = Report.query.filter_by(listing_id=http_listing_id).first() + assert rep is not None and rep.reason == ReportReason.spam + report_id = rep.id + assert db.session.get(Listing, http_listing_id).status == ListingStatus.flagged + print("user-facing report route: ok") + + # admin sees it in the open reports queue, dismisses it, it disappears + c.get("/auth/logout", base_url=B) + login("admin@example.com", "AdminPass123") + r = c.get("/admin/reports", base_url=B) + assert "HTTP Report Test" in r.get_data(as_text=True) + tok = csrf(r.get_data(as_text=True)) + c.post(f"/admin/reports/{report_id}/dismiss", base_url=B, + data={"csrf_token": tok}, + headers={"Referer": B + "/admin/reports"}, follow_redirects=True) + r2 = c.get("/admin/reports", base_url=B) + assert "HTTP Report Test" not in r2.get_data(as_text=True) + print("report dismiss hides from open queue: 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()