Add reports to database

This commit is contained in:
rubenwardy
2025-08-24 22:07:31 +01:00
parent 80d06d154a
commit a9f82b6e1b
7 changed files with 238 additions and 31 deletions

View File

@@ -19,19 +19,21 @@ from flask_babel import lazy_gettext
from flask_login import current_user
from flask_wtf import FlaskForm
from werkzeug.utils import redirect
from wtforms import TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length
from wtforms import TextAreaField, SubmitField, URLField, StringField, RadioField
from wtforms.validators import InputRequired, Length, Optional
from app.models import User, UserRank
from app.tasks.emails import send_user_email
from app.models import User, UserRank, Report, db, AuditSeverity, Thread
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_no, abs_url_samesite, normalize_line_endings
from app.utils import is_no, abs_url_samesite, normalize_line_endings, rank_required, add_audit_log, abs_url_for, \
add_replies
bp = Blueprint("report", __name__)
class ReportForm(FlaskForm):
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)], filters=[normalize_line_endings])
url = URLField(lazy_gettext("URL"), [Optional()])
title = StringField(lazy_gettext("Subject / Title"), [InputRequired(), Length(10, 300)])
message = TextAreaField(lazy_gettext("Message"), [Optional(), Length(0, 10000)], filters=[normalize_line_endings])
submit = SubmitField(lazy_gettext("Report"))
@@ -48,23 +50,68 @@ def report():
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None
if form and request.method == "GET":
form.url.data = url
form.message.data = request.args.get("message", "")
if form and form.validate_on_submit():
report = Report()
report.user = current_user if current_user.is_authenticated else None
form.populate_obj(report)
if not current_user.is_authenticated:
ip_addr = request.headers.get("X-Forwarded-For") or request.remote_addr
report.message = ip_addr + "\n\n" + report.message
db.session.add(report)
db.session.flush()
if current_user.is_authenticated:
user_info = f"{current_user.username}"
add_audit_log(AuditSeverity.USER, current_user, f"New report: {report.title}",
url_for("report.view", rid=report.id))
db.session.commit()
abs_url = abs_url_for("report.view", rid=report.id)
msg = f"**New Report**\nReport on `{report.url}`\n\n{report.title}\n\nView: {abs_url}"
post_discord_webhook.delay(None if is_anon else current_user.username, msg, True)
return redirect(url_for("report.view", rid=report.id))
return render_template("report/report.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
@bp.route("/admin/reports/")
@rank_required(UserRank.MODERATOR)
def list_all():
reports = Report.query.order_by(db.desc(Report.is_resolved), db.desc(Report.created_at)).all()
return render_template("report/list.html", reports=reports)
class ResolveForm(FlaskForm):
completed = SubmitField(lazy_gettext("Completed / resolved"))
removed = SubmitField(lazy_gettext("Content removed"))
invalid = SubmitField(lazy_gettext("Invalid / No action taken"))
@bp.route("/admin/reports/<int:rid>/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def view(rid: int):
report = Report.query.get_or_404(rid)
resolve_form = ResolveForm(request.form)
if resolve_form.validate_on_submit():
if resolve_form.completed.data:
outcome = "completed"
elif resolve_form.removed.data:
outcome = "removed"
elif resolve_form.invalid.data:
outcome = "invalid"
else:
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
abort(400)
text = f"{url}\n\n{form.message.data}"
report.is_resolved = True
url = url_for("report.view", rid=report.id)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Resolved report as {outcome} \"{report.title}\"", url)
db.session.commit()
task = None
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
task = send_user_email.delay(admin.email, admin.locale or "en",
f"User report from {user_info}", text)
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
return render_template("report/view.html", report=report, resolve_form=resolve_form)

View File

@@ -130,6 +130,24 @@ class AuditLogEntry(db.Model):
raise Exception("Permission {} is not related to audit log entries".format(perm.name))
class Report(db.Model):
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="reports")
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True)
thread = db.relationship("Thread", foreign_keys=[thread_id])
url = db.Column(db.String, nullable=True)
title = db.Column(db.Unicode(300), nullable=False)
message = db.Column(db.UnicodeText, nullable=False)
is_resolved = db.Column(db.Boolean, nullable=False, default=False)
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
"minetest.net", "luanti.org", "dropboxusercontent.com", "4shared.com",
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",

View File

@@ -210,6 +210,7 @@ class User(db.Model, UserMixin):
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
reports = db.relationship("Report", back_populates="user", lazy="dynamic", cascade="all")
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title -%}
{{ _("Reports") }}
{%- endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<nav class="list-group">
{% for report in reports %}
<a class="list-group-item list-group-item-action" href="{{ url_for('report.view', rid=report.id) }}">
{% if report.is_resolved %}
<span class="badge bg-secondary me-3">
Closed
</span>
{% else %}
<span class="badge bg-warning me-3">
Open
</span>
{% endif %}
{{ report.title }}
</a>
{% else %}
<span>
No reports.
</span>
{% endfor %}
</nav>
{% endblock %}

View File

@@ -4,6 +4,10 @@
{{ _("Report") }}
{%- endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %}
{% block scriptextra %}
{{ easymde_scripts() }}
{% endblock %}
{% block content %}
@@ -22,23 +26,17 @@
{% else %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% if url %}
<p>
URL: <code>{{ url }}</code>
</p>
{% endif %}
{{ render_field(form.message, hint=_("What are you reporting? Why are you reporting it?")) }}
{{ render_field(form.url) }}
{{ render_field(form.title) }}
{{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }}
{{ render_submit_field(form.submit) }}
<p class="mt-5 text-muted">
{{ _("Reports will be shared with ContentDB staff.") }}
{% if is_anon %}
{{ _("Only the admin will be able to see who made the report.") }}
{% endif %}
{{ _("The full report will be visible to all ContentDB staff members, including editors and moderators.") }}
{{ _("If you are reporting content posted by another user, we may discuss the report with them but will not disclose who reported it.") }}
{{ _("If you are reporting content by an editor or moderator, then you should email the admin or a moderator directly to hide your identity.") }}
</p>
<p class="alert alert-info">
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title -%}
{{ report.title }}
{%- endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field, easymde_scripts %}
{% block scriptextra %}
{{ easymde_scripts() }}
{% endblock %}
{% block content %}
<p>
<a href="{{ url_for('report.list_all') }}">Back to reports</a>
</p>
<h1>
{% if report.is_resolved %}
<span class="badge bg-secondary me-3">
Closed
</span>
{% else %}
<span class="badge bg-warning me-3">
Open
</span>
{% endif %}
{{ self.title() }}
</h1>
<article class="row">
<div class="col-md-9">
<div class="card">
<div class="card-body markdown">
{{ report.message | markdown }}
</div>
</div>
</div>
<aside class="col-md-3 info-sidebar">
<dl>
<dt>URL</dt>
<dd>
<a href="{{ report.url }}">{{ report.url }}</a>
</dd>
</dl>
<dl>
<dt>Created At</dt>
<dd>
{{ report.created_at | full_datetime }}
</dd>
</dl>
<dl>
<dt>Reporter</dt>
<dd>
{% if report.user %}
<a href="{{ url_for('users.profile', username=report.user.username) }}">
{{ report.user.username }}
</a>
{% else %}
Anonymous
{% endif %}
</dd>
</dl>
</aside>
</article>
{% if not report.is_resolved %}
<article>
<h2>Quick resolve</h2>
<form method="POST" action="">
{{ resolve_form.hidden_tag() }}
{{ render_submit_field(resolve_form.completed) }}
{{ render_submit_field(resolve_form.removed) }}
{{ render_submit_field(resolve_form.invalid) }}
</form>
</article>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '3052712496e4'
down_revision = '663521dfe86d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('report',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('thread_id', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('title', sa.Unicode(length=300), nullable=False),
sa.Column('message', sa.UnicodeText(), nullable=False),
sa.Column('is_resolved', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('report')