Converts report "comments" into broader "actions" table

This table will now track all actions taken on a report, like resolving
it, re-opening it, suspending the reported user, et cetera, in addition
to comments. When there are multiple admins, this change will make it
easier to understand what actions have been taken by whom on a report.
This commit is contained in:
Mouse Reeve 2023-05-16 09:43:00 -07:00
parent ab146f652a
commit b3a519c082
11 changed files with 185 additions and 68 deletions

View file

@ -6,13 +6,31 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0178_auto_20230328_2132'), ("bookwyrm", "0178_auto_20230328_2132"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='reportcomment', model_name="reportcomment",
name='comment_type', 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_deletion', 'Deleted user account'), ('block_domain', 'Blocked domain'), ('approve_domain', 'Approved domain'), ('delete_item', 'Deleted item')], default='comment', max_length=20), 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

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

View file

@ -7,6 +7,21 @@ from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel 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): class Report(BookWyrmModel):
"""reported status or user""" """reported status or user"""
@ -33,30 +48,60 @@ class Report(BookWyrmModel):
def get_remote_id(self): def get_remote_id(self):
return f"https://{DOMAIN}/settings/reports/{self.id}" 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: class Meta:
"""set order by default""" """set order by default"""
ordering = ("-created_date",) ordering = ("-created_date",)
ReportCommentTypes = [ ReportActionTypes = [
("comment", _("Comment")), (COMMENT, _("Comment")),
("resolve", _("Resolved report")), (RESOLVE, _("Resolved report")),
("reopen", _("Re-opened report")), (REOPEN, _("Re-opened report")),
("message_reporter", _("Messaged reporter")), (MESSAGE_REPORTER, _("Messaged reporter")),
("message_offender", _("Messaged reported user")), (MESSAGE_OFFENDER, _("Messaged reported user")),
("user_suspension", _("Suspended user")), (USER_SUSPENSION, _("Suspended user")),
("user_deletion", _("Deleted user account")), (USER_UNSUSPENSION, _("Un-suspended user")),
("block_domain", _("Blocked domain")), (USER_PERMS, _("Changed user permission level")),
("approve_domain", _("Approved domain")), (USER_DELETION, _("Deleted user account")),
("delete_item", _("Deleted item")), (BLOCK_DOMAIN, _("Blocked domain")),
(APPROVE_DOMAIN, _("Approved domain")),
(DELETE_ITEM, _("Deleted item")),
] ]
class ReportComment(BookWyrmModel):
class ReportAction(BookWyrmModel):
"""updates on a report""" """updates on a report"""
user = models.ForeignKey("User", on_delete=models.PROTECT) user = models.ForeignKey("User", on_delete=models.PROTECT)
comment_type = models.CharField( action_type = models.CharField(
max_length=20, blank=False, default="comment", choices=ReportCommentTypes max_length=20, blank=False, default="comment", choices=ReportActionTypes
) )
note = models.TextField() note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT) report = models.ForeignKey(Report, on_delete=models.PROTECT)

View file

@ -1,6 +1,7 @@
{% extends 'settings/layout.html' %} {% extends 'settings/layout.html' %}
{% load i18n %} {% load i18n %}
{% load humanize %} {% load humanize %}
{% load utilities %}
{% load feed_page_tags %} {% load feed_page_tags %}
{% block title %} {% block title %}
@ -21,7 +22,7 @@
<div class="block"> <div class="block">
<details class="details-panel box"> <details class="details-panel box">
<summary> <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> <span class="details-close icon icon-x" aria-hidden="true"></span>
</summary> </summary>
<div class="box"> <div class="box">
@ -61,30 +62,59 @@
{% include 'settings/users/user_moderation_actions.html' with user=report.user %} {% include 'settings/users/user_moderation_actions.html' with user=report.user %}
{% endif %} {% endif %}
<div class="block"> <div class="block content">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3> <h3 class="title is-4">{% trans "Moderation Activity" %}</h3>
{% for comment in report.reportcomment_set.all %}
<div class="card block"> <div class="box">
<p class="card-content">{{ comment.note }}</p> <ul class="mt-0">
<div class="card-footer"> {% for comment in report.reportaction_set.all %}
<div class="card-footer-item"> <li class="mb-2">
<a href="{{ comment.user.local_path }}">{{ comment.user.display_name }}</a> <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 %}
<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>
</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>
<div class="card-footer-item"> <div class="field">
{{ comment.created_date|naturaltime }} <button class="button">{% trans "Comment" %}</button>
</div> </div>
</div> </form>
</div> </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> </div>
{% endblock %} {% endblock %}

View file

@ -13,8 +13,18 @@
</a> </a>
</td> </td>
<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> <button type="submit" class="button is-danger is-light">{% trans "Block domain" %}</button>
</form> </form>
{% endif %}
</td> </td>
{% endblock %} {% endblock %}

View file

@ -6,7 +6,7 @@
{% endblock %} {% endblock %}
{% block form %} {% 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 %} {% csrf_token %}
<p> <p>
{% blocktrans trimmed with username=user.localname %} {% blocktrans trimmed with username=user.localname %}

View file

@ -22,12 +22,12 @@
</form> </form>
{% endif %} {% endif %}
{% if user.is_active or user.deactivation_reason == "pending" %} {% 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 %} {% csrf_token %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button> <button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
</form> </form>
{% else %} {% 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 %} {% csrf_token %}
<button class="button">{% trans "Un-suspend user" %}</button> <button class="button">{% trans "Un-suspend user" %}</button>
</form> </form>
@ -49,7 +49,7 @@
{% if user.local %} {% if user.local %}
<div> <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 %} {% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label> <label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %} {% if group_form.non_field_errors %}

View file

@ -141,7 +141,7 @@ urlpatterns = [
name="settings-users", name="settings-users",
), ),
re_path( re_path(
r"^settings/users/(?P<user>\d+)/?$", r"^settings/users/(?P<user>\d+)/(?P<report_id>\d+)??$",
views.UserAdmin.as_view(), views.UserAdmin.as_view(),
name="settings-user", name="settings-user",
), ),
@ -231,7 +231,7 @@ urlpatterns = [
name="settings-link-domain", name="settings-link-domain",
), ),
re_path( 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, views.update_domain_status,
name="settings-link-domain-status", name="settings-link-domain-status",
), ),
@ -275,17 +275,17 @@ urlpatterns = [
name="settings-report", name="settings-report",
), ),
re_path( 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, views.suspend_user,
name="settings-report-suspend", name="settings-report-suspend",
), ),
re_path( 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, views.unsuspend_user,
name="settings-report-unsuspend", name="settings-report-unsuspend",
), ),
re_path( 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, views.moderator_delete_user,
name="settings-delete-user", name="settings-delete-user",
), ),

View file

@ -7,6 +7,8 @@ from django.views import View
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from bookwyrm import forms, models 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 # pylint: disable=no-self-use
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
@ -46,11 +48,17 @@ class LinkDomain(View):
@require_POST @require_POST
@login_required @login_required
@permission_required("bookwyrm.moderate_user") @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""" """This domain seems fine"""
domain = get_object_or_404(models.LinkDomain, id=domain_id) domain = get_object_or_404(models.LinkDomain, id=domain_id)
domain.raise_not_editable(request.user) domain.raise_not_editable(request.user)
domain.status = status domain.status = status
domain.save() 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 django.views import View
from bookwyrm import forms, models 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.views.helpers import redirect_to_referer
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
@ -81,41 +82,42 @@ class ReportAdmin(View):
def post(self, request, report_id): def post(self, request, report_id):
"""comment on a report""" """comment on a report"""
report = get_object_or_404(models.Report, id=report_id) report = get_object_or_404(models.Report, id=report_id)
models.ReportComment.objects.create( note = request.POST.get("note")
user=request.user, report.comment(request.user, note)
report=report,
note=request.POST.get("note"),
)
return redirect("settings-report", report.id) return redirect("settings-report", report.id)
@login_required @login_required
@permission_required("bookwyrm.moderate_user") @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""" """mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id) user = get_object_or_404(models.User, id=user_id)
user.is_active = False user.is_active = False
user.deactivation_reason = "moderator_suspension" user.deactivation_reason = "moderator_suspension"
# this isn't a full deletion, so we don't want to tell the world # this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False) user.save(broadcast=False)
models.ReportAction.record_action(report_id, USER_SUSPENSION, request.user)
return redirect_to_referer(request, "settings-user", user.id) return redirect_to_referer(request, "settings-user", user.id)
@login_required @login_required
@permission_required("bookwyrm.moderate_user") @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""" """mark an account as inactive"""
user = get_object_or_404(models.User, id=user_id) user = get_object_or_404(models.User, id=user_id)
user.is_active = True user.is_active = True
user.deactivation_reason = None user.deactivation_reason = None
# this isn't a full deletion, so we don't want to tell the world # this isn't a full deletion, so we don't want to tell the world
user.save(broadcast=False) user.save(broadcast=False)
models.ReportAction.record_action(report_id, USER_UNSUSPENSION, request.user)
return redirect_to_referer(request, "settings-user", user.id) return redirect_to_referer(request, "settings-user", user.id)
@login_required @login_required
@permission_required("bookwyrm.moderate_user") @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""" """permanently delete a user"""
user = get_object_or_404(models.User, id=user_id) 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"]): if form.is_valid() and moderator.check_password(form.cleaned_data["password"]):
user.deactivation_reason = "moderator_deletion" user.deactivation_reason = "moderator_deletion"
user.delete() user.delete()
# make a note of the fact that we did this
models.ReportAction.record_action(report_id, USER_DELETION, request.user)
return redirect_to_referer(request, "settings-user", user.id) return redirect_to_referer(request, "settings-user", user.id)
form.errors["password"] = ["Invalid password"] form.errors["password"] = ["Invalid password"]
@ -140,11 +145,12 @@ def moderator_delete_user(request, user_id):
@login_required @login_required
@permission_required("bookwyrm.moderate_post") @permission_required("bookwyrm.moderate_post")
def resolve_report(_, report_id): def resolve_report(request, report_id):
"""mark a report as (un)resolved""" """mark a report as (un)resolved"""
report = get_object_or_404(models.Report, id=report_id) report = get_object_or_404(models.Report, id=report_id)
report.resolved = not report.resolved if report.resolved:
report.save() report.reopen(request.user)
if not report.resolved:
return redirect("settings-report", report.id) return redirect("settings-report", report.id)
report.resolve(request.user)
return redirect("settings-reports") return redirect("settings-reports")

View file

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