Report function and admin

pull/184/head
Andrew Godwin 2022-12-17 14:45:31 -07:00
rodzic b3b2c6effd
commit e8d6dccbb2
16 zmienionych plików z 615 dodań i 4 usunięć

Wyświetl plik

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

Wyświetl plik

@ -709,7 +709,9 @@ button,
}
button.delete,
.button.delete {
.button.delete,
button.danger,
.button.danger {
background: var(--color-delete);
}
@ -833,6 +835,20 @@ table.metadata th {
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 {
margin: 0 0 20px 0;
}

Wyświetl plik

@ -7,7 +7,7 @@ from api.views import api_router, oauth
from core import views as core
from mediaproxy import views as mediaproxy
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 = [
path("", core.homepage),
@ -114,6 +114,16 @@ urlpatterns = [
admin.IdentityEdit.as_view(),
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(
"admin/invites/",
admin.Invites.as_view(),
@ -147,6 +157,7 @@ urlpatterns = [
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/report/", report.SubmitReport.as_view()),
# Posts
path("compose/", compose.Compose.as_view(), name="compose"),
path(
@ -160,6 +171,7 @@ urlpatterns = [
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>/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()),
# Authentication
path("auth/login/", auth.Login.as_view(), name="login"),

Wyświetl plik

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

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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 %}

Wyświetl plik

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

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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 %}

Wyświetl plik

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

Wyświetl plik

@ -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,
},
),
]

Wyświetl plik

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

Wyświetl plik

@ -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,
}

Wyświetl plik

@ -17,6 +17,7 @@ from users.views.admin.hashtags import ( # noqa
Hashtags,
)
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
BasicSettings,
PoliciesSettings,

Wyświetl plik

@ -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

Wyświetl plik

@ -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