From e8d6dccbb27a8611311b5f94f593b69bcca99528 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sat, 17 Dec 2022 14:45:31 -0700 Subject: Report function and admin --- users/admin.py | 6 ++ users/migrations/0005_report.py | 119 ++++++++++++++++++++++++++++++++++++ users/models/__init__.py | 1 + users/models/report.py | 129 ++++++++++++++++++++++++++++++++++++++++ users/views/admin/__init__.py | 1 + users/views/admin/reports.py | 80 +++++++++++++++++++++++++ users/views/report.py | 76 +++++++++++++++++++++++ 7 files changed, 412 insertions(+) create mode 100644 users/migrations/0005_report.py create mode 100644 users/models/report.py create mode 100644 users/views/admin/reports.py create mode 100644 users/views/report.py (limited to 'users') diff --git a/users/admin.py b/users/admin.py index 6c89881..e780b16 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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"] diff --git a/users/migrations/0005_report.py b/users/migrations/0005_report.py new file mode 100644 index 0000000..a4556b2 --- /dev/null +++ b/users/migrations/0005_report.py @@ -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, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index 1c5f519..4e271ba 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -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 diff --git a/users/models/report.py b/users/models/report.py new file mode 100644 index 0000000..7bafd0c --- /dev/null +++ b/users/models/report.py @@ -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, + } diff --git a/users/views/admin/__init__.py b/users/views/admin/__init__.py index d923f80..0e054d2 100644 --- a/users/views/admin/__init__.py +++ b/users/views/admin/__init__.py @@ -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, diff --git a/users/views/admin/reports.py b/users/views/admin/reports.py new file mode 100644 index 0000000..6187068 --- /dev/null +++ b/users/views/admin/reports.py @@ -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 diff --git a/users/views/report.py b/users/views/report.py new file mode 100644 index 0000000..f7e29ed --- /dev/null +++ b/users/views/report.py @@ -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 -- cgit v1.2.3