Report function and admin

This commit is contained in:
Andrew Godwin 2022-12-17 14:45:31 -07:00
parent b3b2c6effd
commit e8d6dccbb2
16 changed files with 615 additions and 4 deletions

View File

@ -251,6 +251,7 @@ class Post(StatorModel):
action_unboost = "{view}unboost/" action_unboost = "{view}unboost/"
action_delete = "{view}delete/" action_delete = "{view}delete/"
action_edit = "{view}edit/" action_edit = "{view}edit/"
action_report = "{view}report/"
action_reply = "/compose/?reply_to={self.id}" action_reply = "/compose/?reply_to={self.id}"
admin_edit = "/djadmin/activities/post/{self.id}/change/" admin_edit = "/djadmin/activities/post/{self.id}/change/"

View File

@ -709,7 +709,9 @@ button,
} }
button.delete, button.delete,
.button.delete { .button.delete,
button.danger,
.button.danger {
background: var(--color-delete); background: var(--color-delete);
} }
@ -833,6 +835,20 @@ table.metadata th {
font-weight: bold; font-weight: bold;
} }
table.buttons {
margin: -10px 0 10px 0;
text-align: left;
}
table.buttons th {
padding: 5px 20px 5px 0;
text-align: center;
}
table.buttons th button {
width: 100%;
}
.stats { .stats {
margin: 0 0 20px 0; margin: 0 0 20px 0;
} }

View File

@ -7,7 +7,7 @@ from api.views import api_router, oauth
from core import views as core from core import views as core
from mediaproxy import views as mediaproxy from mediaproxy import views as mediaproxy
from stator import views as stator from stator import views as stator
from users.views import activitypub, admin, auth, identity, settings from users.views import activitypub, admin, auth, identity, report, settings
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
@ -114,6 +114,16 @@ urlpatterns = [
admin.IdentityEdit.as_view(), admin.IdentityEdit.as_view(),
name="admin_identity_edit", name="admin_identity_edit",
), ),
path(
"admin/reports/",
admin.ReportsRoot.as_view(),
name="admin_reports",
),
path(
"admin/reports/<id>/",
admin.ReportView.as_view(),
name="admin_report_view",
),
path( path(
"admin/invites/", "admin/invites/",
admin.Invites.as_view(), admin.Invites.as_view(),
@ -147,6 +157,7 @@ urlpatterns = [
path("@<handle>/inbox/", activitypub.Inbox.as_view()), path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()), path("@<handle>/action/", identity.ActionIdentity.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()), path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/report/", report.SubmitReport.as_view()),
# Posts # Posts
path("compose/", compose.Compose.as_view(), name="compose"), path("compose/", compose.Compose.as_view(), name="compose"),
path( path(
@ -160,6 +171,7 @@ urlpatterns = [
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()), path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()), path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view(), name="login"), path("auth/login/", auth.Login.as_view(), name="login"),

View File

@ -37,6 +37,9 @@
<a href="{{ post.urls.view }}" role="menuitem"> <a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies <i class="fa-solid fa-comment"></i> View Post &amp; Replies
</a> </a>
<a href="{{ post.urls.action_report }}" role="menuitem">
<i class="fa-solid fa-flag"></i> Report
</a>
{% if post.author == request.identity %} {% if post.author == request.identity %}
<a href="{{ post.urls.action_edit }}" role="menuitem"> <a href="{{ post.urls.action_edit }}" role="menuitem">
<i class="fa-solid fa-pen-to-square"></i> Edit <i class="fa-solid fa-pen-to-square"></i> Edit

View File

@ -0,0 +1,84 @@
{% extends "settings/base.html" %}
{% block subtitle %}Report {{ report.pk }}{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Report</legend>
<label>Report about</label>
{% if report.subject_post %}
{% include "activities/_mini_post.html" with post=report.subject_post %}
{% else %}
{% include "activities/_identity.html" with identity=report.subject_identity %}
{% endif %}
<label>Reported by</label>
{% if report.source_identity %}
{% include "activities/_identity.html" with identity=report.source_identity %}
{% else %}
<p>Remote server {{ report.source_domain.domain }}</p>
{% endif %}
<label>Complaint</label>
<p>{{ report.complaint|linebreaks }}</p>
{% if report.resolved %}
<label>Resolved</label>
<p>
{{ report.resolved|timesince }} ago by
<a href="{{ report.moderator.urls.view }}">{{ report.moderator.name_or_handle }}</a>
</p>
{% endif %}
</fieldset>
<fieldset>
<legend>Moderator Notes</legend>
{% include "forms/_field.html" with field=form.notes %}
</fieldset>
<fieldset>
<legend>Resolution Options</legend>
<table class="buttons">
<tr>
{% if report.resolved and report.valid %}
<th><button disabled="true">Resolve Valid</button></th>
<td>Report is already resolved as valid</td>
{% else %}
<th><button name="valid">Resolve Valid</button></th>
<td>Mark report against the identity but take no further action</td>
{% endif %}
</tr>
<tr>
{% if report.resolved and not report.valid %}
<th><button disabled="true">Resolve Invalid</button></th>
<td>Report is already resolved as invalid</td>
{% else %}
<th><button name="invalid">Resolve Invalid</button></th>
<td>Mark report as invalid and take no action</td>
{% endif %}
</tr>
<tr>
{% if report.subject_identity.limited %}
<th><button class="danger" disabled="true">Limit</button></th>
<td>User is already limited</td>
{% else %}
<th><button class="danger" name="limit">Limit</button></th>
<td>Make them less visible on this server</td>
{% endif %}
</tr>
<tr>
{% if report.subject_identity.blocked %}
<th><button class="danger" disabled="true">Block</button></th>
<td>User is already blocked</td>
{% else %}
<th><button class="danger" name="block">Block</button></th>
<td>Remove their existence entirely from this server</td>
{% endif %}
</tr>
</table>
</fieldset>
<div class="buttons">
<a href="{{ report.urls.admin }}" class="button secondary left">Back</a>
<a href="{{ report.subject_identity.urls.view }}" class="button secondary">View Profile</a>
<a href="{{ report.subject_identity.urls.admin_edit }}" class="button secondary">Identity Admin</a>
<button>Save Notes</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends "settings/base.html" %}
{% load activity_tags %}
{% block subtitle %}Reports{% endblock %}
{% block content %}
<div class="view-options">
{% if all %}
<a href="." class="selected"><i class="fa-solid fa-check"></i> Show Resolved</a>
{% else %}
<a href=".?all=true"><i class="fa-solid fa-xmark"></i> Show Resolved</a>
{% endif %}
</div>
<section class="icon-menu">
{% for report in page_obj %}
<a class="option" href="{{ report.urls.admin_view }}">
<img src="{{ report.subject_identity.local_icon_url.relative }}" class="icon" alt="Avatar for {{ report.subject_identity.name_or_handle }}">
<span class="handle">
{{ report.subject_identity.html_name_or_handle }}
{% if report.subject_post %}
(post {{ report.subject_post.pk }})
{% endif %}
<small>
{{ report.type|title }}
</small>
</span>
<time>{{ report.created|timedeltashort }} ago</time>
</a>
{% empty %}
<p class="option empty">
There are no {% if all %}reports yet{% else %}unresolved reports{% endif %}.
</p>
{% endfor %}
<div class="load-more">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if all %}&amp;all=true{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if all %}&amp;all=true{% endif %}">Next Page</a>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -39,8 +39,8 @@
<a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags"> <a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
<i class="fa-solid fa-hashtag"></i> Hashtags <i class="fa-solid fa-hashtag"></i> Hashtags
</a> </a>
<a href="{% url "admin_tuning" %}" {% if section == "tuning" %}class="selected"{% endif %} title="Tuning"> <a href="{% url "admin_reports" %}" {% if section == "reports" %}class="selected"{% endif %} title="Reports">
<i class="fa-solid fa-wrench"></i> Tuning <i class="fa-solid fa-flag"></i> Reports
</a> </a>
<a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator"> <a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator">
<i class="fa-solid fa-clock-rotate-left"></i> Stator <i class="fa-solid fa-clock-rotate-left"></i> Stator

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Report{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Report</legend>
<label>Reporting</label>
{% if post %}
{% include "activities/_mini_post.html" %}
{% else %}
{% include "activities/_identity.html" %}
{% endif %}
{% include "forms/_field.html" with field=form.type %}
{% include "forms/_field.html" with field=form.complaint %}
{% if not identity.local %}
{% include "forms/_field.html" with field=form.forward %}
{% endif %}
</fieldset>
<div class="buttons">
<button>Send Report</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Report Sent{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Report</legend>
<p>Your report has been sent.</p>
</fieldset>
</form>
{% endblock %}

View File

@ -9,6 +9,7 @@ from users.models import (
InboxMessage, InboxMessage,
Invite, Invite,
PasswordReset, PasswordReset,
Report,
User, User,
UserEvent, UserEvent,
) )
@ -113,3 +114,8 @@ class InboxMessageAdmin(admin.ModelAdmin):
@admin.register(Invite) @admin.register(Invite)
class InviteAdmin(admin.ModelAdmin): class InviteAdmin(admin.ModelAdmin):
list_display = ["id", "created", "token", "note"] list_display = ["id", "created", "token", "note"]
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
list_display = ["id", "created", "resolved", "type", "subject_identity"]

View File

@ -0,0 +1,119 @@
# Generated by Django 4.1.4 on 2022-12-17 20:38
import django.db.models.deletion
from django.db import migrations, models
import stator.models
import users.models.report
class Migration(migrations.Migration):
dependencies = [
("activities", "0004_emoji_post_emojis"),
("users", "0004_identity_admin_notes_identity_restriction_and_more"),
]
operations = [
migrations.CreateModel(
name="Report",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state_ready", models.BooleanField(default=True)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_attempted", models.DateTimeField(blank=True, null=True)),
("state_locked_until", models.DateTimeField(blank=True, null=True)),
(
"state",
stator.models.StateField(
choices=[("new", "new"), ("sent", "sent")],
default="new",
graph=users.models.report.ReportStates,
max_length=100,
),
),
(
"type",
models.CharField(
choices=[
("spam", "Spam"),
("hateful", "Hateful"),
("illegal", "Illegal"),
("remote", "Remote"),
("other", "Other"),
],
max_length=100,
),
),
("complaint", models.TextField()),
("forward", models.BooleanField(default=False)),
("valid", models.BooleanField(null=True)),
("seen", models.DateTimeField(blank=True, null=True)),
("resolved", models.DateTimeField(blank=True, null=True)),
("notes", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_reports",
to="users.identity",
),
),
(
"source_domain",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="filed_reports",
to="users.domain",
),
),
(
"source_identity",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="filed_reports",
to="users.identity",
),
),
(
"subject_identity",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="reports",
to="users.identity",
),
),
(
"subject_post",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reports",
to="activities.post",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -5,6 +5,7 @@ from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .invite import Invite # noqa from .invite import Invite # noqa
from .password_reset import PasswordReset # noqa from .password_reset import PasswordReset # noqa
from .report import Report # noqa
from .system_actor import SystemActor # noqa from .system_actor import SystemActor # noqa
from .user import User # noqa from .user import User # noqa
from .user_event import UserEvent # noqa from .user_event import UserEvent # noqa

129
users/models/report.py Normal file
View File

@ -0,0 +1,129 @@
import httpx
import urlman
from django.db import models
from core.ld import canonicalise
from stator.models import State, StateField, StateGraph, StatorModel
from users.models import SystemActor
class ReportStates(StateGraph):
new = State(try_interval=600)
sent = State()
new.transitions_to(sent)
@classmethod
async def handle_new(cls, instance: "Report"):
"""
Sends the report to the remote server if we need to
"""
report = await instance.afetch_full()
if report.forward and not report.subject_identity.domain.local:
system_actor = SystemActor()
try:
await system_actor.signed_request(
method="post",
uri=report.subject_identity.inbox_uri,
body=canonicalise(report.to_ap()),
)
except httpx.RequestError:
return
return cls.sent
class Report(StatorModel):
"""
A complaint about a user or post.
"""
class Types(models.TextChoices):
spam = "spam"
hateful = "hateful"
illegal = "illegal"
remote = "remote"
other = "other"
state = StateField(ReportStates)
subject_identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="reports",
)
subject_post = models.ForeignKey(
"activities.Post",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="reports",
)
source_identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="filed_reports",
)
source_domain = models.ForeignKey(
"users.Domain",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="filed_reports",
)
moderator = models.ForeignKey(
"users.Identity",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="moderated_reports",
)
type = models.CharField(max_length=100, choices=Types.choices)
complaint = models.TextField()
forward = models.BooleanField(default=False)
valid = models.BooleanField(null=True)
seen = models.DateTimeField(blank=True, null=True)
resolved = models.DateTimeField(blank=True, null=True)
notes = models.TextField(blank=True, null=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class urls(urlman.Urls):
admin = "/admin/reports/"
admin_view = "{admin}{self.pk}/"
### ActivityPub ###
async def afetch_full(self) -> "Report":
return await Report.objects.select_related(
"source_identity",
"source_domain",
"subject_identity__domain",
"subject_identity",
"subject_post",
).aget(pk=self.pk)
def to_ap(self):
system_actor = SystemActor()
if self.subject_post:
objects = [
self.subject_post.object_uri,
self.subject_identity.actor_uri,
]
else:
objects = self.subject_identity.actor_uri
return {
"id": f"https://{self.source_domain.uri_domain}/reports/{self.id}/",
"type": "Flag",
"actor": system_actor.actor_uri,
"object": objects,
"content": self.complaint,
}

View File

@ -17,6 +17,7 @@ from users.views.admin.hashtags import ( # noqa
Hashtags, Hashtags,
) )
from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa from users.views.admin.identities import IdentitiesRoot, IdentityEdit # noqa
from users.views.admin.reports import ReportsRoot, ReportView # noqa
from users.views.admin.settings import ( # noqa from users.views.admin.settings import ( # noqa
BasicSettings, BasicSettings,
PoliciesSettings, PoliciesSettings,

View File

@ -0,0 +1,80 @@
from django import forms
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.generic import FormView, ListView
from users.decorators import admin_required
from users.models import Identity, Report
@method_decorator(admin_required, name="dispatch")
class ReportsRoot(ListView):
template_name = "admin/reports.html"
paginate_by = 30
def get(self, request, *args, **kwargs):
self.query = request.GET.get("query")
self.all = request.GET.get("all")
self.extra_context = {
"section": "reports",
"all": self.all,
}
return super().get(request, *args, **kwargs)
def get_queryset(self):
reports = Report.objects.select_related(
"subject_post", "subject_identity"
).order_by("created")
if not self.all:
reports = reports.filter(resolved__isnull=True)
return reports
@method_decorator(admin_required, name="dispatch")
class ReportView(FormView):
template_name = "admin/report_view.html"
extra_context = {
"section": "reports",
}
class form_class(forms.Form):
notes = forms.CharField(widget=forms.Textarea, required=False)
def dispatch(self, request, id, *args, **kwargs):
self.report = get_object_or_404(Report, id=id)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if "limit" in request.POST:
self.report.subject_identity.restriction = Identity.Restriction.limited
self.report.subject_identity.save()
if "block" in request.POST:
self.report.subject_identity.restriction = Identity.Restriction.blocked
self.report.subject_identity.save()
if "valid" in request.POST:
self.report.resolved = timezone.now()
self.report.valid = True
self.report.moderator = self.request.identity
self.report.save()
if "invalid" in request.POST:
self.report.resolved = timezone.now()
self.report.valid = False
self.report.moderator = self.request.identity
self.report.save()
return super().post(request, *args, **kwargs)
def get_initial(self):
return {"notes": self.report.notes}
def form_valid(self, form):
self.report.notes = form.cleaned_data["notes"]
self.report.save()
return redirect(".")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["report"] = self.report
return context

76
users/views/report.py Normal file
View File

@ -0,0 +1,76 @@
from django import forms
from django.shortcuts import get_object_or_404, render
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from users.decorators import identity_required
from users.models import Report
from users.shortcuts import by_handle_or_404
@method_decorator(identity_required, name="dispatch")
class SubmitReport(FormView):
"""
Submits a report on a user or a post
"""
template_name = "users/report.html"
class form_class(forms.Form):
type = forms.ChoiceField(
choices=[
("", "------"),
("spam", "Spam or inappropriate advertising"),
("hateful", "Hateful, abusive, or violent speech"),
("other", "Something else"),
],
label="Why are you reporting this?",
)
complaint = forms.CharField(
widget=forms.Textarea,
help_text="Please describe why you think this should be removed",
)
forward = forms.BooleanField(
widget=forms.Select(
choices=[
(False, "Do not send to other server"),
(True, "Send to other server"),
]
),
help_text="Should we also send an anonymous copy of this to their server?",
required=False,
)
def dispatch(self, request, handle, post_id=None):
self.identity = by_handle_or_404(self.request, handle, local=False)
if post_id:
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
else:
self.post_obj = None
return super().dispatch(request)
def form_valid(self, form):
# Create the report
report = Report.objects.create(
type=form.cleaned_data["type"],
complaint=form.cleaned_data["complaint"],
subject_identity=self.identity,
subject_post=self.post_obj,
source_identity=self.request.identity,
source_domain=self.request.identity.domain,
forward=form.cleaned_data.get("forward", False),
)
# Show a thanks page
return render(
self.request,
"users/report_sent.html",
{"report": report},
)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context["identity"] = self.identity
context["post"] = self.post_obj
return context