Abuse reports: moderator form for resolving/dismissing + notification #173
@ -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',
|
||||
)
|
||||
},
|
||||
),
|
||||
|
@ -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)
|
||||
|
31
abuse/migrations/0010_abusereport_moderator_note_and_more.py
Normal file
31
abuse/migrations/0010_abusereport_moderator_note_and_more.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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: [],
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user