06/16 Phase 6
This commit is contained in:
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||||
|
{% if pagination.has_next %}<a href="{{ url_for('admin.listings', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user