Merge pull request #2854 from bookwyrm-social/report-actions

Record report actions
This commit is contained in:
Mouse Reeve 2023-08-06 16:52:24 -07:00 committed by GitHub
commit 15e82ece07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 313 additions and 70 deletions

View file

@ -0,0 +1,36 @@
# Generated by Django 3.2.18 on 2023-05-16 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0178_auto_20230328_2132"),
]
operations = [
migrations.AddField(
model_name="reportcomment",
name="action_type",
field=models.CharField(
choices=[
("comment", "Comment"),
("resolve", "Resolved report"),
("reopen", "Re-opened report"),
("message_reporter", "Messaged reporter"),
("message_offender", "Messaged reported user"),
("user_suspension", "Suspended user"),
("user_unsuspension", "Un-suspended user"),
("user_perms", "Changed user permission level"),
("user_deletion", "Deleted user account"),
("block_domain", "Blocked domain"),
("approve_domain", "Approved domain"),
("delete_item", "Deleted item"),
],
default="comment",
max_length=20,
),
),
migrations.RenameModel("ReportComment", "ReportAction"),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-06-21 22:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0179_reportcomment_comment_type"),
]
operations = [
migrations.AlterModelOptions(
name="reportaction",
options={"ordering": ("created_date",)},
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.20 on 2023-08-06 23:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0180_alter_reportaction_options"),
("bookwyrm", "0180_alter_user_preferred_language"),
]
operations = []

View file

@ -20,7 +20,7 @@ from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .user import User, KeyPair
from .annual_goal import AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .report import Report, ReportAction
from .federated_server import FederatedServer
from .group import Group, GroupMember, GroupMemberInvitation

View file

@ -1,11 +1,27 @@
""" flagged for moderation """
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
# Report action enums
COMMENT = "comment"
RESOLVE = "resolve"
REOPEN = "reopen"
MESSAGE_REPORTER = "message_reporter"
MESSAGE_OFFENDER = "message_offender"
USER_SUSPENSION = "user_suspension"
USER_UNSUSPENSION = "user_unsuspension"
USER_DELETION = "user_deletion"
USER_PERMS = "user_perms"
BLOCK_DOMAIN = "block_domain"
APPROVE_DOMAIN = "approve_domain"
DELETE_ITEM = "delete_item"
class Report(BookWyrmModel):
"""reported status or user"""
@ -32,20 +48,65 @@ class Report(BookWyrmModel):
def get_remote_id(self):
return f"https://{DOMAIN}/settings/reports/{self.id}"
def comment(self, user, note):
"""comment on a report"""
ReportAction.objects.create(
action_type=COMMENT, user=user, note=note, report=self
)
def resolve(self, user):
"""Mark a report as complete"""
self.resolved = True
self.save()
ReportAction.objects.create(action_type=RESOLVE, user=user, report=self)
def reopen(self, user):
"""Wait! This report isn't complete after all"""
self.resolved = False
self.save()
ReportAction.objects.create(action_type=REOPEN, user=user, report=self)
@classmethod
def record_action(cls, report_id: int, action: str, user):
"""Note that someone did something"""
if not report_id:
return
report = cls.objects.get(id=report_id)
ReportAction.objects.create(action_type=action, user=user, report=report)
class Meta:
"""set order by default"""
ordering = ("-created_date",)
class ReportComment(BookWyrmModel):
ReportActionTypes = [
(COMMENT, _("Comment")),
(RESOLVE, _("Resolved report")),
(REOPEN, _("Re-opened report")),
(MESSAGE_REPORTER, _("Messaged reporter")),
(MESSAGE_OFFENDER, _("Messaged reported user")),
(USER_SUSPENSION, _("Suspended user")),
(USER_UNSUSPENSION, _("Un-suspended user")),
(USER_PERMS, _("Changed user permission level")),
(USER_DELETION, _("Deleted user account")),
(BLOCK_DOMAIN, _("Blocked domain")),
(APPROVE_DOMAIN, _("Approved domain")),
(DELETE_ITEM, _("Deleted item")),
]
class ReportAction(BookWyrmModel):
"""updates on a report"""
user = models.ForeignKey("User", on_delete=models.PROTECT)
action_type = models.CharField(
max_length=20, blank=False, default="comment", choices=ReportActionTypes
)
note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT)
class Meta:
"""sort comments"""
ordering = ("-created_date",)
ordering = ("created_date",)

View file

@ -1,6 +1,7 @@
{% extends 'settings/layout.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% load feed_page_tags %}
{% block title %}
@ -21,7 +22,7 @@
<div class="block">
<details class="details-panel box">
<summary>
<span class="title is-4">{% trans "Message reporter" %}</span>
<span class="title is-6">{% trans "Message reporter" %}</span>
<span class="details-close icon icon-x" aria-hidden="true"></span>
</summary>
<div class="box">
@ -61,30 +62,59 @@
{% include 'settings/users/user_moderation_actions.html' with user=report.user %}
{% endif %}
<div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
{% for comment in report.reportcomment_set.all %}
<div class="card block">
<p class="card-content">{{ comment.note }}</p>
<div class="card-footer">
<div class="card-footer-item">
<a href="{{ comment.user.local_path }}">{{ comment.user.display_name }}</a>
<div class="block content">
<h3 class="title is-4">{% trans "Moderation Activity" %}</h3>
<div class="box">
<ul class="mt-0">
<li class="mb-2">
<div class="is-flex">
<p class="mb-0 is-flex-grow-1">
{% blocktrans trimmed with user=report.reporter|username user_link=report.reporter.local_path %}
<a href="{{ user_link }}">{{ user}}</a> opened this report
{% endblocktrans %}
</p>
<span class="tag">{{ report.created_date }}</span>
</div>
</li>
{% for comment in report.reportaction_set.all %}
<li class="mb-2">
<div class="is-flex">
<p class="mb-0 is-flex-grow-1">
{% if comment.action_type == "comment" %}
{% blocktrans trimmed with user=comment.user|username user_link=comment.user.local_path %}
<a href="{{ user_link }}">{{ user}}</a> commented on this report:
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with user=comment.user|username user_link=comment.user.local_path %}
<a href="{{ user_link }}">{{ user}}</a> took an action on this report:
{% endblocktrans %}
<span class="has-text-weight-bold">
{{ comment.get_action_type_display }}
</span>
{% endif %}
</p>
<span class="tag">{{ comment.created_date }}</span>
</div>
{% if comment.note %}
<blockquote>{{ comment.note }}</blockquote>
{% endif %}
</li>
{% endfor %}
</ul>
<form class="block" name="report-comment" method="post" action="{% url 'settings-report' report.id %}">
{% csrf_token %}
<div class="field">
<label for="report_comment" class="label">Comment on report</label>
<textarea name="note" id="report_comment" class="textarea"></textarea>
</div>
<div class="card-footer-item">
{{ comment.created_date|naturaltime }}
<div class="field">
<button class="button">{% trans "Comment" %}</button>
</div>
</div>
</form>
</div>
{% endfor %}
<form class="block" name="report-comment" method="post" action="{% url 'settings-report' report.id %}">
{% csrf_token %}
<div class="field">
<label for="report_comment" class="label">Comment on report</label>
<textarea name="note" id="report_comment" class="textarea"></textarea>
</div>
<div class="field">
<button class="button">{% trans "Comment" %}</button>
</div>
</form>
</div>
{% endblock %}

View file

@ -13,8 +13,18 @@
</a>
</td>
<td>
<form>
{% if link.domain.status != "approved" %}
<form method="POST" action="{% url 'settings-link-domain-status' link.domain.id 'approved' report.id %}">
{% csrf_token %}
<button type="submit" class="button is-success is-light">{% trans "Approve domain" %}</button>
</form>
{% endif %}
{% if link.domain.status != "blocked" %}
<form method="POST" action="{% url 'settings-link-domain-status' link.domain.id 'blocked' report.id %}">
{% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Block domain" %}</button>
</form>
{% endif %}
</td>
{% endblock %}

View file

@ -6,7 +6,7 @@
{% endblock %}
{% block form %}
<form name="delete-user" action="{% url 'settings-delete-user' user.id %}" method="post">
<form name="delete-user" action="{% url 'settings-delete-user' user.id report.id %}" method="post">
{% csrf_token %}
<p>
{% blocktrans trimmed with username=user.localname %}

View file

@ -22,12 +22,12 @@
</form>
{% endif %}
{% if user.is_active or user.deactivation_reason == "pending" %}
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}" class="mr-1">
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id report.id %}" class="mr-1">
{% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
</form>
{% else %}
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id %}" class="mr-1">
<form name="unsuspend" method="post" action="{% url 'settings-report-unsuspend' user.id report.id %}" class="mr-1">
{% csrf_token %}
<button class="button">{% trans "Un-suspend user" %}</button>
</form>
@ -49,7 +49,7 @@
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
<form name="permission" method="post" action="{% url 'settings-user' user.id report.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}

View file

@ -18,7 +18,7 @@
<div class="card-footer-item">
{# moderation options #}
<form name="delete-{{ status.id }}" action="/delete-status/{{ status.id }}" method="post">
<form name="delete-{{ status.id }}" action="/delete-status/{{ status.id }}/{{ report.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}

View file

@ -78,8 +78,8 @@ class ReportViews(TestCase):
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_report_comment(self):
"""comment on a report"""
def test_report_action(self):
"""action on a report"""
view = views.ReportAdmin.as_view()
request = self.factory.post("", {"note": "hi"})
request.user = self.local_user
@ -87,15 +87,17 @@ class ReportViews(TestCase):
view(request, report.id)
comment = models.ReportComment.objects.get()
self.assertEqual(comment.user, self.local_user)
self.assertEqual(comment.note, "hi")
self.assertEqual(comment.report, report)
action = models.ReportAction.objects.get()
self.assertEqual(action.user, self.local_user)
self.assertEqual(action.note, "hi")
self.assertEqual(action.report, report)
self.assertEqual(action.action_type, "comment")
def test_resolve_report(self):
"""toggle report resolution status"""
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
self.assertFalse(report.resolved)
self.assertFalse(models.ReportAction.objects.exists())
request = self.factory.post("")
request.user = self.local_user
@ -104,11 +106,25 @@ class ReportViews(TestCase):
report.refresh_from_db()
self.assertTrue(report.resolved)
# check that the action was noted
self.assertTrue(
models.ReportAction.objects.filter(
report=report, action_type="resolve", user=self.local_user
).exists()
)
# un-resolve
views.resolve_report(request, report.id)
report.refresh_from_db()
self.assertFalse(report.resolved)
# check that the action was noted
self.assertTrue(
models.ReportAction.objects.filter(
report=report, action_type="reopen", user=self.local_user
).exists()
)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.suggested_users.remove_user_task.delay")

View file

@ -7,6 +7,7 @@ from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.models.report import USER_PERMS
from bookwyrm.management.commands import initdb
from bookwyrm.tests.validate_html import validate_html
@ -79,3 +80,37 @@ class UserAdminViews(TestCase):
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), ["editor"]
)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.suggested_users.remove_user_task.delay")
def test_user_admin_page_post_with_report(self, *_):
"""set the user's group"""
group = Group.objects.get(name="editor")
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), ["moderator"]
)
report = models.Report.objects.create(
user=self.local_user, reporter=self.local_user
)
view = views.UserAdmin.as_view()
request = self.factory.post("", {"groups": [group.id]})
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
result = view(request, self.local_user.id, report.id)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), ["editor"]
)
# make sure a report action was created
self.assertTrue(
models.ReportAction.objects.filter(
report=report, action_type=USER_PERMS
).exists()
)

View file

@ -141,12 +141,12 @@ urlpatterns = [
name="settings-users",
),
re_path(
r"^settings/users/(?P<user>\d+)/?$",
r"^settings/users/(?P<user_id>\d+)/(?P<report_id>\d+)?$",
views.UserAdmin.as_view(),
name="settings-user",
),
re_path(
r"^settings/users/(?P<user>\d+)/activate/?$",
r"^settings/users/(?P<user_id>\d+)/activate/?$",
views.ActivateUserAdmin.as_view(),
name="settings-activate-user",
),
@ -231,7 +231,7 @@ urlpatterns = [
name="settings-link-domain",
),
re_path(
r"^setting/link-domains/(?P<domain_id>\d+)/(?P<status>(pending|approved|blocked))/?$",
r"^setting/link-domains/(?P<domain_id>\d+)/(?P<status>(pending|approved|blocked))/(?P<report_id>\d+)?$",
views.update_domain_status,
name="settings-link-domain-status",
),
@ -275,17 +275,17 @@ urlpatterns = [
name="settings-report",
),
re_path(
r"^settings/reports/(?P<user_id>\d+)/suspend/?$",
r"^settings/reports/(?P<user_id>\d+)/suspend/(?P<report_id>\d+)?$",
views.suspend_user,
name="settings-report-suspend",
),
re_path(
r"^settings/reports/(?P<user_id>\d+)/unsuspend/?$",
r"^settings/reports/(?P<user_id>\d+)/unsuspend/(?P<report_id>\d+)?$",
views.unsuspend_user,
name="settings-report-unsuspend",
),
re_path(
r"^settings/reports/(?P<user_id>\d+)/delete/?$",
r"^settings/reports/(?P<user_id>\d+)/delete/(?P<report_id>\d+)?$",
views.moderator_delete_user,
name="settings-delete-user",
),
@ -633,7 +633,7 @@ urlpatterns = [
name="create-status",
),
re_path(
r"^delete-status/(?P<status_id>\d+)/?$",
r"^delete-status/(?P<status_id>\d+)/?(?P<report_id>\d+)?$",
views.DeleteStatus.as_view(),
name="delete-status",
),

View file

@ -7,6 +7,8 @@ from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.models.report import APPROVE_DOMAIN, BLOCK_DOMAIN
from bookwyrm.views.helpers import redirect_to_referer
# pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch")
@ -46,11 +48,17 @@ class LinkDomain(View):
@require_POST
@login_required
@permission_required("bookwyrm.moderate_user")
def update_domain_status(request, domain_id, status):
def update_domain_status(request, domain_id, status, report_id=None):
"""This domain seems fine"""
domain = get_object_or_404(models.LinkDomain, id=domain_id)
domain.raise_not_editable(request.user)
domain.status = status
domain.save()
return redirect("settings-link-domain", status="pending")
if status == "approved":
models.Report.record_action(report_id, APPROVE_DOMAIN, request.user)
elif status == "blocked":
models.Report.record_action(report_id, BLOCK_DOMAIN, request.user)
return redirect_to_referer(request, "settings-link-domain", status="pending")

View file

@ -8,6 +8,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.models.report import USER_SUSPENSION, USER_UNSUSPENSION, USER_DELETION
from bookwyrm.views.helpers import redirect_to_referer
from bookwyrm.settings import PAGE_LENGTH
@ -81,41 +82,42 @@ class ReportAdmin(View):
def post(self, request, report_id):
"""comment on a report"""
report = get_object_or_404(models.Report, id=report_id)
models.ReportComment.objects.create(
user=request.user,
report=report,
note=request.POST.get("note"),
)
note = request.POST.get("note")
report.comment(request.user, note)
return redirect("settings-report", report.id)
@login_required
@permission_required("bookwyrm.moderate_user")
def suspend_user(request, user_id):
def suspend_user(request, user_id, report_id=None):
"""mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id)
user.is_active = False
user.deactivation_reason = "moderator_suspension"
# this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False)
models.Report.record_action(report_id, USER_SUSPENSION, request.user)
return redirect_to_referer(request, "settings-user", user.id)
@login_required
@permission_required("bookwyrm.moderate_user")
def unsuspend_user(request, user_id):
def unsuspend_user(request, user_id, report_id=None):
"""mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id)
user.is_active = True
user.deactivation_reason = None
# this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False)
models.Report.record_action(report_id, USER_UNSUSPENSION, request.user)
return redirect_to_referer(request, "settings-user", user.id)
@login_required
@permission_required("bookwyrm.moderate_user")
def moderator_delete_user(request, user_id):
def moderator_delete_user(request, user_id, report_id=None):
"""permanently delete a user"""
user = get_object_or_404(models.User, id=user_id)
@ -130,6 +132,9 @@ def moderator_delete_user(request, user_id):
if form.is_valid() and moderator.check_password(form.cleaned_data["password"]):
user.deactivation_reason = "moderator_deletion"
user.delete()
# make a note of the fact that we did this
models.Report.record_action(report_id, USER_DELETION, request.user)
return redirect_to_referer(request, "settings-user", user.id)
form.errors["password"] = ["Invalid password"]
@ -140,11 +145,12 @@ def moderator_delete_user(request, user_id):
@login_required
@permission_required("bookwyrm.moderate_post")
def resolve_report(_, report_id):
def resolve_report(request, report_id):
"""mark a report as (un)resolved"""
report = get_object_or_404(models.Report, id=report_id)
report.resolved = not report.resolved
report.save()
if not report.resolved:
if report.resolved:
report.reopen(request.user)
return redirect("settings-report", report.id)
report.resolve(request.user)
return redirect("settings-reports")

View file

@ -7,6 +7,7 @@ from django.utils.decorators import method_decorator
from django.views import View
from bookwyrm import forms, models
from bookwyrm.models.report import USER_PERMS
from bookwyrm.settings import PAGE_LENGTH
@ -76,15 +77,16 @@ class UserAdminList(View):
class UserAdmin(View):
"""moderate an individual user"""
def get(self, request, user):
# pylint: disable=unused-argument
def get(self, request, user_id, report_id=None):
"""user view"""
user = get_object_or_404(models.User, id=user)
user = get_object_or_404(models.User, id=user_id)
data = {"user": user, "group_form": forms.UserGroupForm()}
return TemplateResponse(request, "settings/users/user.html", data)
def post(self, request, user):
def post(self, request, user_id, report_id=None):
"""update user group"""
user = get_object_or_404(models.User, id=user)
user = get_object_or_404(models.User, id=user_id)
if request.POST.get("groups") == "":
user.groups.set([])
@ -93,6 +95,10 @@ class UserAdmin(View):
form = forms.UserGroupForm(request.POST, instance=user)
if form.is_valid():
form.save(request)
if report_id:
models.Report.record_action(report_id, USER_PERMS, request.user)
data = {"user": user, "group_form": form}
return TemplateResponse(request, "settings/users/user.html", data)
@ -106,8 +112,8 @@ class ActivateUserAdmin(View):
"""activate a user manually"""
# pylint: disable=unused-argument
def post(self, request, user):
def post(self, request, user_id):
"""activate user"""
user = get_object_or_404(models.User, id=user)
user = get_object_or_404(models.User, id=user_id)
user.reactivate()
return redirect("settings-user", user.id)

View file

@ -222,7 +222,7 @@ def maybe_redirect_local_path(request, model):
return redirect(new_path, permanent=True)
def redirect_to_referer(request, *args):
def redirect_to_referer(request, *args, **kwargs):
"""Redirect to the referrer, if it's in our domain, with get params"""
# make sure the refer is part of this instance
validated = validate_url_domain(request.META.get("HTTP_REFERER"))
@ -231,4 +231,4 @@ def redirect_to_referer(request, *args):
return redirect(validated)
# if not, use the args passed you'd normally pass to redirect()
return redirect(*args or "/")
return redirect(*args or "/", **kwargs)

View file

@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
@ -18,6 +18,7 @@ from django.views.decorators.http import require_POST
from markdown import markdown
from bookwyrm import forms, models
from bookwyrm.models.report import DELETE_ITEM
from bookwyrm.utils import regex, sanitizer
from .helpers import handle_remote_webfinger, is_api_request
from .helpers import load_date_in_user_tz_as_utc, redirect_to_referer
@ -167,7 +168,7 @@ def format_hashtags(content, hashtags):
class DeleteStatus(View):
"""tombstone that bad boy"""
def post(self, request, status_id):
def post(self, request, status_id, report_id=None):
"""delete and tombstone a status"""
status = get_object_or_404(models.Status, id=status_id)
@ -176,7 +177,11 @@ class DeleteStatus(View):
# perform deletion
status.delete()
return redirect("/")
# record deletion if it's related to a report
if report_id:
models.Report.record_action(report_id, DELETE_ITEM, request.user)
return redirect_to_referer(request, "/")
@login_required