06/16 Phase 6

This commit is contained in:
2026-06-16 13:29:32 -04:00
parent 2e9025fe55
commit 479afde2fa
16 changed files with 527 additions and 8 deletions
+98 -1
View File
@@ -9,10 +9,14 @@ from app.models.listing import Listing
from app.models.plan import Plan from app.models.plan import Plan
from app.models.trust import TrustEvent from app.models.trust import TrustEvent
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.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.utils import admin_required
from app.services import admin_users as usvc from app.services import admin_users as usvc
from app.services import admin_dashboard as dash 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__) admin_bp = Blueprint("admin", __name__)
@@ -125,3 +129,96 @@ def trust_adjust(user_id):
db.session.commit() db.session.commit()
flash(_("Trust adjusted."), "success") flash(_("Trust adjusted."), "success")
return redirect(url_for("admin.user_detail", user_id=user.id)) 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/<int:listing_id>/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/<int:listing_id>/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/<int:listing_id>/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/<int:report_id>/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/<int:report_id>/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"))
+24 -1
View File
@@ -9,10 +9,11 @@ from flask_babel import gettext as _
from app.extensions import db, limiter from app.extensions import db, limiter
from app.models.category import Category from app.models.category import Category
from app.models.listing import Listing, ListingImage 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 import listings as svc
from app.services.geo import geocode_zip from app.services.geo import geocode_zip
from app.services.images import process_upload, delete_image_files, ImageError 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 from app.blueprints.listings.forms import ListingForm, ImageUploadForm
listings_bp = Blueprint("listings", __name__) listings_bp = Blueprint("listings", __name__)
@@ -220,6 +221,28 @@ def mark_sold(listing_id):
return redirect(url_for("listings.detail", listing_id=listing.id)) return redirect(url_for("listings.detail", listing_id=listing.id))
# --- report ---
@listings_bp.route("/listings/<int:listing_id>/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 --- # --- my listings ---
@listings_bp.route("/my/listings") @listings_bp.route("/my/listings")
@login_required @login_required
+2 -1
View File
@@ -7,6 +7,7 @@ from app.models.listing import Listing, ListingImage
from app.models.geo import ZipGeo, Metro from app.models.geo import ZipGeo, Metro
from app.models.messaging import Conversation, Message from app.models.messaging import Conversation, Message
from app.models.favorite import Favorite from app.models.favorite import Favorite
from app.models.report import Report
from app.models.payments import Subscription, Transaction, Boost from app.models.payments import Subscription, Transaction, Boost
from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.audit import AuditLog from app.models.audit import AuditLog
@@ -14,5 +15,5 @@ from app.models.setting import Setting
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite", "Subscription", "Transaction", "Boost", "Favorite", "Report", "Subscription", "Transaction", "Boost",
"Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"] "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"]
+9
View File
@@ -42,3 +42,12 @@ class Lang(str, enum.Enum):
en = "en" en = "en"
vi = "vi" vi = "vi"
es = "es" es = "es"
class ReportReason(str, enum.Enum):
spam = "spam"
scam = "scam"
offensive = "offensive"
duplicate = "duplicate"
miscategorized = "miscategorized"
other = "other"
+28
View File
@@ -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"<Report L{self.listing_id} by u{self.reporter_id} {self.reason.value}>"
+2 -1
View File
@@ -41,4 +41,5 @@ def revenue_30d_cents():
def flag_queue_depth(): 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()
+14
View File
@@ -8,6 +8,7 @@ from app.models.enums import ListingStatus, Lang
from app.utils.text import normalize from app.utils.text import normalize
from app.services.geo import geocode_zip, bounding_box, haversine_mi from app.services.geo import geocode_zip, bounding_box, haversine_mi
from app.services.field_schema import validate_attributes, hot_values from app.services.field_schema import validate_attributes, hot_values
from app.services.settings import get_setting
class ListingError(ValueError): class ListingError(ValueError):
@@ -48,6 +49,15 @@ def _life_days(user):
return _limit(user, "listing_life_days", 14) 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 --- # --- create / update ---
def create_listing(user, category, *, title, body, lang, price_cents, def create_listing(user, category, *, title, body, lang, price_cents,
zip_code, raw_attributes): 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)), expires_at=datetime.utcnow() + timedelta(days=_life_days(user)),
**hot_values(cleaned), **hot_values(cleaned),
) )
if _blocklist_hit(title, body):
listing.status = ListingStatus.flagged
_apply_location(listing, zip_code) _apply_location(listing, zip_code)
db.session.add(listing) db.session.add(listing)
db.session.commit() db.session.commit()
@@ -91,6 +103,8 @@ def update_listing(listing, category, *, title, body, lang, price_cents,
listing.attributes = cleaned listing.attributes = cleaned
for col, val in hot_values(cleaned).items(): for col, val in hot_values(cleaned).items():
setattr(listing, col, val) setattr(listing, col, val)
if listing.status == ListingStatus.active and _blocklist_hit(title, body):
listing.status = ListingStatus.flagged
_apply_location(listing, zip_code) _apply_location(listing, zip_code)
db.session.commit() db.session.commit()
return listing return listing
+43
View File
@@ -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)
+32
View File
@@ -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
+3
View File
@@ -198,3 +198,6 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.kpi-value{font-size:26px;font-weight:800} .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{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap}
.admin-filter-row .field{margin-bottom:0} .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}
+2
View File
@@ -1,4 +1,6 @@
<nav class="admin-nav"> <nav class="admin-nav">
<a href="{{ url_for('admin.dashboard') }}" class="{{ 'on' if request.endpoint == 'admin.dashboard' else '' }}">{{ _('Dashboard') }}</a> <a href="{{ url_for('admin.dashboard') }}" class="{{ 'on' if request.endpoint == 'admin.dashboard' else '' }}">{{ _('Dashboard') }}</a>
<a href="{{ url_for('admin.users') }}" class="{{ 'on' if request.endpoint in ('admin.users', 'admin.user_detail') else '' }}">{{ _('Users') }}</a> <a href="{{ url_for('admin.users') }}" class="{{ 'on' if request.endpoint in ('admin.users', 'admin.user_detail') else '' }}">{{ _('Users') }}</a>
<a href="{{ url_for('admin.listings') }}" class="{{ 'on' if request.endpoint == 'admin.listings' else '' }}">{{ _('Listings') }}</a>
<a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a>
</nav> </nav>
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Listings') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Moderation settings') }}</h2>
<form method="post" action="{{ url_for('admin.listings_settings') }}" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Auto-flag threshold (distinct reports)') }}</label>
<input class="input" type="number" name="flag_threshold" value="{{ flag_threshold }}" min="1">
</div>
<div class="field">
<label>{{ _('Keyword blocklist (one per line)') }}</label>
<textarea class="input" name="keyword_blocklist">{{ keyword_blocklist|join('\n') }}</textarea>
</div>
<button class="btn" type="submit">{{ _('Save settings') }}</button>
</form>
<h2>{{ _('Flag queue') }}</h2>
{% if not pagination.items %}<p class="muted">{{ _('No flagged listings.') }}</p>{% endif %}
<table class="list">
{% for l in pagination.items %}
<tr>
<td><a href="{{ url_for('listings.detail', listing_id=l.id) }}">{{ l.title }}</a></td>
<td>{{ l.flag_count }} {{ _('flags') }}</td>
<td><span class="badge {{ 'warn' if l.status.value == 'flagged' else 'ok' }}">{{ l.status.value }}</span></td>
<td class="muted small">{{ l.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="post" action="{{ url_for('admin.approve_listing', listing_id=l.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn tiny" type="submit">{{ _('Approve') }}</button>
</form>
<form method="post" action="{{ url_for('admin.hide_listing', listing_id=l.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Hide') }}</button>
</form>
<form method="post" action="{{ url_for('admin.remove_listing', listing_id=l.id) }}" style="display:inline"
onsubmit="return confirm('{{ _('Remove this listing?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Remove') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
<div class="pager">
{% if pagination.has_prev %}<a href="{{ url_for('admin.listings', **merge_query(page=pagination.prev_num)) }}">&larr; {{ _('Prev') }}</a>{% endif %}
{% if pagination.has_next %}<a href="{{ url_for('admin.listings', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} &rarr;</a>{% endif %}
</div>
</div>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Reports') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Open reports') }}</h2>
{% if not reports %}<p class="muted">{{ _('No open reports.') }}</p>{% endif %}
<table class="list">
{% for r in reports %}
<tr>
<td><a href="{{ url_for('listings.detail', listing_id=r.listing_id) }}">{{ r.listing.title }}</a></td>
<td>{{ r.reporter.display_name }}</td>
<td><span class="badge cat">{{ r.reason.value }}</span></td>
<td class="muted small">{{ r.note or '—' }}</td>
<td class="muted small">{{ r.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="post" action="{{ url_for('admin.dismiss_report', report_id=r.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Dismiss') }}</button>
</form>
<form method="post" action="{{ url_for('admin.escalate_report', report_id=r.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn tiny" type="submit">{{ _('Escalate') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+11
View File
@@ -62,6 +62,17 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost" type="submit">{{ _('♥ Save listing') }}</button> <button class="btn ghost" type="submit">{{ _('♥ Save listing') }}</button>
</form> </form>
<form method="post" class="report-form"
action="{{ url_for('listings.report', listing_id=listing.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select class="input" name="reason">
{% for r in ['spam', 'scam', 'offensive', 'duplicate', 'miscategorized', 'other'] %}
<option value="{{ r }}">{{ r }}</option>
{% endfor %}
</select>
<input class="input" name="note" placeholder="{{ _('Optional note') }}">
<button class="btn ghost tiny" type="submit">{{ _('Report listing') }}</button>
</form>
{% else %} {% else %}
<a class="btn" href="{{ url_for('auth.login') }}?next={{ request.path }}"> <a class="btn" href="{{ url_for('auth.login') }}?next={{ request.path }}">
{{ _('Sign in to contact') }} {{ _('Sign in to contact') }}
@@ -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 ###
+129 -4
View File
@@ -489,6 +489,12 @@ def _phase6(app):
from app.services import admin_users as usvc from app.services import admin_users as usvc
from app.services import admin_dashboard as dash from app.services import admin_dashboard as dash
from app.services.settings import get_setting, set_setting 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(): with app.app_context():
admin = User(email="admin@example.com", display_name="Admin", admin = User(email="admin@example.com", display_name="Admin",
@@ -547,6 +553,95 @@ def _phase6(app):
usvc.set_status(target, UserStatus.active, actor=admin) usvc.set_status(target, UserStatus.active, actor=admin)
db.session.commit() 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 --- # --- route checks ---
c = app.test_client(); B = "https://localhost" c = app.test_client(); B = "https://localhost"
@@ -562,15 +657,17 @@ def _phase6(app):
# non-admin gets 403 # non-admin gets 403
login("t@example.com", "NewPass456") login("t@example.com", "NewPass456")
assert c.get("/admin", base_url=B).status_code == 403 for path in ("/admin", "/admin/listings", "/admin/reports"):
print("non-admin /admin -> 403: ok") assert c.get(path, base_url=B).status_code == 403
print("non-admin /admin* -> 403: ok")
c.get("/auth/logout", base_url=B) c.get("/auth/logout", base_url=B)
# admin gets 200 on dashboard/users/user_detail # admin gets 200 on dashboard/users/user_detail/listings/reports
login("admin@example.com", "AdminPass123") login("admin@example.com", "AdminPass123")
with app.app_context(): with app.app_context():
target_id = User.query.filter_by(email="t@example.com").first().id target_id = User.query.filter_by(email="t@example.com").first().id
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 code = c.get(path, base_url=B).status_code
assert code == 200, f"{path} -> {code}" assert code == 200, f"{path} -> {code}"
print(f"{code} {path}") print(f"{code} {path}")
@@ -590,6 +687,34 @@ def _phase6(app):
assert "suspended" in r.get_data(as_text=True).lower() assert "suspended" in r.get_data(as_text=True).lower()
print("banned user login rejected: ok") 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) # restore target to active for cleanliness (not strictly needed, smoke ends here)
with app.app_context(): with app.app_context():
u = User.query.filter_by(email="t@example.com").first() u = User.query.filter_by(email="t@example.com").first()