Abuse reports: moderator form for resolving/dismissing + notification #173

Merged
Oleg-Komarov merged 7 commits from resolve-report-form into main 2024-06-07 17:04:54 +02:00
14 changed files with 179 additions and 28 deletions

View File

@ -19,8 +19,7 @@ class AbuseReportTypeFilter(admin.SimpleListFilter):
parameter_name = 'type'
def lookups(self, request, model_admin):
"""
Returns a list of tuples. The first element in each
"""Returns a list of tuples. The first element in each
tuple is the coded value for the option that will
appear in the URL query. The second element is the
human-readable name for the option that will appear
@ -32,8 +31,7 @@ class AbuseReportTypeFilter(admin.SimpleListFilter):
)
def queryset(self, request, queryset):
"""
Returns the filtered queryset based on the value
"""Returns the filtered queryset based on the value
provided in the query string and retrievable via
`self.value()`.
"""
@ -78,6 +76,8 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
'message',
'extension_version',
'version',
'processed_by',
'moderator_note',
)
fieldsets = (
('Abuse Report Core Information', {'fields': ('status', 'reason', 'message')}),
@ -89,6 +89,8 @@ class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
'date_modified',
'reporter',
'version',
'processed_by',
'moderator_note',
)
},
),

View File

@ -41,3 +41,28 @@ class ReportRatingForm(forms.ModelForm):
class Meta:
model = abuse.models.AbuseReport
fields = ('reason', 'message')
class ResolveReportForm(forms.ModelForm):
class Meta:
model = abuse.models.AbuseReport
fields = ('moderator_note',)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['moderator_note'].required = True
def clean(self):
super().clean()
if 'dismiss' in self.data:
self.instance.status = self.instance.STATUSES.DISMISSED
if 'resolve' in self.data:
self.instance.status = self.instance.STATUSES.RESOLVED
self.instance.processed_by = self.request.user
return self.cleaned_data
def is_valid(self, *args, **kwargs) -> bool:
if 'dismiss' not in self.data and 'resolve' not in self.data:
return False
return super().is_valid(*args, **kwargs)

View File

@ -0,0 +1,31 @@
# Generated by Django 4.2.11 on 2024-06-07 12:40
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('abuse', '0009_alter_abusereport_user'),
]
operations = [
migrations.AddField(
model_name='abusereport',
name='moderator_note',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='abusereport',
name='processed_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='abuse_reports_processed', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='abusereport',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Untriaged'), (2, 'Dismissed'), (3, 'Resolved')], default=1),
),
]

View File

@ -29,7 +29,7 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
STATUSES = Choices(
('UNTRIAGED', 1, 'Untriaged'),
('CONFIRMED', 2, 'Confirmed'),
('DISMISSED', 2, 'Dismissed'),
('RESOLVED', 3, 'Resolved'),
)
@ -68,6 +68,13 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
)
status = models.PositiveSmallIntegerField(default=STATUSES.UNTRIAGED, choices=STATUSES.choices)
moderator_note = models.TextField(blank=True, null=True)
processed_by = models.ForeignKey(
User,
null=True,
related_name='abuse_reports_processed',
on_delete=models.PROTECT,
)
@classmethod
def lookup_country_code_from_ip(cls, ip):

View File

@ -1,11 +1,12 @@
import logging
from actstream import action
from actstream.actions import follow
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from abuse.models import AbuseReport
from constants.activity import Verb
from constants.activity import Flag, Verb
from constants.base import (
ABUSE_TYPE_EXTENSION,
ABUSE_TYPE_RATING,
@ -45,6 +46,8 @@ def _create_action_from_report(
target=instance.extension,
action_object=instance,
)
# subscribe to the report object to get notifications about its resolution
follow(instance.reporter, instance, send_action=False, flag=Flag.REPORTER)
@receiver(pre_delete, sender=AbuseReport)

View File

@ -9,6 +9,7 @@
<div class="hero extension-detail">
<div class="container">
<div class="hero-content">
{% if user_is_moderator %}
{% block hero_breadcrumbs %}
<div class="hero-breadcrumbs">
<a href="{% url 'abuse:report-list' %}">
@ -17,6 +18,7 @@
</a>
</div>
{% endblock hero_breadcrumbs %}
{% endif %}
<h1>{{ object.get_type_display }} Report</h1>
@ -73,12 +75,12 @@
<div class="mb-3">
<div class="row">
<div class="col-md-8">
<h2>Reason</h2>
<p>"{{ object.get_reason_display }}"</p>
{% if object.rating %}
{% include "ratings/components/rating.html" with rating=object.rating classes="mb-3" %}
<hr class="my-4">
{% endif %}
<h2>Reason</h2>
<p>"{{ object.get_reason_display }}"</p>
<h2 class="mt-4 mb-3">Message</h2>
<div class="card p-3 mx-1 mb-3">
{% if object.message %}
@ -96,30 +98,25 @@
<dt>Status</dt>
<dd>{% include "common/components/status.html" %}</dd>
</div>
{% if object.processed_by %}
<div class="dl-col">
<dt>Processed by</dt>
<dd>{{ object.processed_by }}</dd>
</div>
{% endif %}
</div>
{% if extension %}
<div class="dl-row">
<div class="dl-col">
{% if extension %}
<dt>{{ extension.get_type_display }}</dt>
<dd>
<a href="{{ extension.get_absolute_url }}">
{{ extension.name }}
</a>
</dd>
{% else %}
<dt>Reported user</dt>
<dd>{{ object.name }}</dd>
</div>
</div>
{% endif %}
</div>
</div>
<div class="dl-row">
<div class="dl-col">
<dt>Compatibility</dt>
<dd>
{% if object.version %}Blender {{ object.version }}{% else %}Other{% endif %}
</dd>
</div>
</div>
<div class="dl-row">
<div class="dl-col">
<dt>Submitted</dt>
@ -145,4 +142,23 @@
</div>
</div>
</div>
{% if user_is_moderator %}
<div class="col-md-8">
<form method="post" enctype="multipart/form-data" class="mt-3">
{% csrf_token %}
{% with form=form|add_form_classes %}
<div class="row mb-3">
<div class="col">
{% include "common/components/field.html" with field=form.moderator_note placeholder="Add a note..." %}
</div>
</div>
{% endwith %}
<button type="submit" class="btn btn-primary" name="resolve"><i class="i-check"></i> Resolve</button>
<button type="submit" class="btn btn-secondary" name="dismiss"><i class="i-trash"></i> Dismiss</button>
</form>
</div>
{% elif object.moderator_note %}
<h2>Moderator note</h2>
<p>{{ object.moderator_note }}</p>
{% endif %}
{% endblock content %}

View File

@ -1,5 +1,5 @@
{% extends "common/base.html" %}
{% load i18n humanize filters %}
{% load common i18n humanize filters %}
{% block page_title %}Approval queue{% endblock page_title %}
@ -25,6 +25,7 @@
<th>{% trans "Reporter" %}</th>
<th>{% trans "Submitted" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Process by" %}</th>
</tr>
</thead>
<tbody>
@ -50,6 +51,9 @@
{% include "common/components/status.html" with object=report classes="d-block" %}
</a>
</td>
<td>
{{ report.processed_by|default:"-" }}
</td>
</tr>
{% endfor %}
</tbody>
@ -58,4 +62,5 @@
<p>{% trans "No extensions to review." %}</p>
{% endif %}
</section>
{{ page_obj|paginator }}
{% endblock content %}

View File

@ -1,8 +1,10 @@
from django.test import TestCase
from abuse.models import AbuseReport
from common.tests.factories.abuse import AbuseReportFactory
from common.tests.factories.extensions import create_approved_version
from common.tests.factories.users import UserFactory, create_moderator
from notifications.models import Notification
POST_DATA = {
'message': 'test message',
@ -61,3 +63,23 @@ class ReportTest(TestCase):
self.client.logout()
self.client.force_login(account)
self.assertEqual(self.client.get(report_url).status_code, 200)
class ResolveReportTest(TestCase):
def test_reporter_gets_notified(self):
report = AbuseReportFactory(
extension=create_approved_version().extension,
status=AbuseReport.STATUSES.UNTRIAGED,
)
notification_nr = Notification.objects.filter(recipient=report.reporter).count()
moderator = create_moderator()
self.client.force_login(moderator)
response = self.client.post(
report.get_absolute_url(), {'moderator_note': 'lalala', 'resolve': ''}
)
self.assertEqual(response.status_code, 302)
report.refresh_from_db()
self.assertEqual(report.status, AbuseReport.STATUSES.RESOLVED)
self.assertEqual(report.processed_by, moderator)
new_notification_nr = Notification.objects.filter(recipient=report.reporter).count()
self.assertEqual(new_notification_nr, notification_nr + 1)

View File

@ -1,13 +1,15 @@
import logging
from actstream import action
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.http import Http404
from django.views.generic import DetailView
from django.db import transaction
from django.http import Http404, HttpResponseForbidden
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView
from django.views.generic.edit import CreateView, UpdateView
from django.shortcuts import get_object_or_404, redirect
from .forms import ReportExtensionForm, ReportRatingForm
from .forms import ReportExtensionForm, ReportRatingForm, ResolveReportForm
from constants.activity import Verb
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
from abuse.models import AbuseReport
from ratings.models import Rating
@ -29,7 +31,7 @@ class ReportList(
return self.request.user.is_moderator
def get_queryset(self):
return AbuseReport.objects.all().order_by('-date_created')
return AbuseReport.objects.all().order_by('status', '-date_created')
template_name = 'abuse/abusereport_list.html'
@ -123,8 +125,9 @@ class ReportRatingView(
return self.object.get_absolute_url()
class ReportView(LoginRequiredMixin, DetailView):
class ReportView(LoginRequiredMixin, UpdateView):
model = AbuseReport
template_name = 'abuse/abusereport_detail.html'
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
@ -133,3 +136,25 @@ class ReportView(LoginRequiredMixin, DetailView):
):
return obj
raise Http404()
def get_form(self):
return ResolveReportForm(**self.get_form_kwargs(), request=self.request)
@transaction.atomic
def form_valid(self, form):
response = super().form_valid(form)
if 'dismiss' in form.data:
verb = Verb.DISMISSED_ABUSE_REPORT
if 'resolve' in form.data:
verb = Verb.RESOLVED_ABUSE_REPORT
action.send(
self.request.user,
verb=verb,
target=form.instance,
)
return response
def post(self, *args, **kwargs):
if not self.request.user.is_moderator:
raise HttpResponseForbidden()
return super().post(*args, **kwargs)

View File

@ -56,6 +56,7 @@ def construct_fake_notifications() -> list['NotificationFactory']:
type=reviewers.models.ApprovalActivity.ActivityType.COMMENT,
message=fake.paragraph(nb_sentences=1),
),
Verb.DISMISSED_ABUSE_REPORT: None,
Verb.RATED_EXTENSION: RatingFactory.build(
text=fake.paragraph(nb_sentences=2),
),
@ -71,6 +72,7 @@ def construct_fake_notifications() -> list['NotificationFactory']:
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
message=fake.paragraph(nb_sentences=1),
),
Verb.RESOLVED_ABUSE_REPORT: None,
}
fake_notifications = [
NotificationFactory.build(

View File

@ -5,15 +5,18 @@ class Verb:
APPROVED = 'approved'
COMMENTED = 'commented'
DISMISSED_ABUSE_REPORT = 'dismissed abuse report'
RATED_EXTENSION = 'rated extension'
REPORTED_EXTENSION = 'reported extension'
REPORTED_RATING = 'reported rating'
REQUESTED_CHANGES = 'requested changes'
REQUESTED_REVIEW = 'requested review'
RESOLVED_ABUSE_REPORT = 'resolved abuse report'
UPLOADED_NEW_VERSION = 'uploaded new version'
class Flag:
AUTHOR = 'author'
MODERATOR = 'moderator'
REPORTER = 'reporter'
REVIEWER = 'reviewer'

View File

@ -4,6 +4,8 @@
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% elif action.verb == Verb.COMMENTED %}
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% elif verb == Verb.DISMISSED_ABUSE_REPORT %}
{% blocktrans %}{{ someone }} dismissed your {{ what }}{% endblocktrans %}
{% elif verb == Verb.RATED_EXTENSION %}
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% elif verb == Verb.REPORTED_EXTENSION %}
@ -14,6 +16,8 @@
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% elif verb == Verb.REQUESTED_REVIEW %}
{% blocktrans %}{{ someone }} {{ verb }} of {{ what }}{% endblocktrans %}
{% elif verb == Verb.RESOLVED_ABUSE_REPORT %}
{% blocktrans %}{{ someone }} resolved your {{ what }}{% endblocktrans %}
{% else %}
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% endif %}

View File

@ -4,6 +4,8 @@
{% blocktrans %}{{ target_type }} approved: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.COMMENTED %}
{% blocktrans %}New comment on {{ what }}{% endblocktrans %}
{% elif verb == Verb.DISMISSED_ABUSE_REPORT %}
{% blocktrans %}Your {{ what }} was dismissed{% endblocktrans %}
{% elif verb == Verb.RATED_EXTENSION %}
{% blocktrans %}{{ target_type }} rated: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REPORTED_EXTENSION %}
@ -14,6 +16,8 @@
{% blocktrans %}{{ target_type }} changes requested: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REQUESTED_REVIEW %}
{% blocktrans %}{{ target_type }} review requested: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.RESOLVED_ABUSE_REPORT %}
{% blocktrans %}Your {{ what }} was resolved{% endblocktrans %}
{% else %}
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% endif %}

View File

@ -13,11 +13,13 @@ logger = logging.getLogger(__name__)
VERB2FLAGS = {
Verb.APPROVED: [Flag.AUTHOR, Flag.REVIEWER],
Verb.COMMENTED: [Flag.AUTHOR, Flag.REVIEWER],
Verb.DISMISSED_ABUSE_REPORT: [Flag.REPORTER],
Verb.RATED_EXTENSION: [Flag.AUTHOR],
Verb.REPORTED_EXTENSION: [Flag.MODERATOR],
Verb.REPORTED_RATING: [Flag.MODERATOR],
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.REVIEWER],
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
Verb.RESOLVED_ABUSE_REPORT: [Flag.REPORTER],
Verb.UPLOADED_NEW_VERSION: [],
}