See #890: web UI and email notifications on new reports
This commit is contained in:
parent
368b70d3a9
commit
c9a9615be8
|
@ -725,3 +725,7 @@ TAGS_MAX_BY_OBJ = env.int("TAGS_MAX_BY_OBJ", default=30)
|
||||||
FEDERATION_OBJECT_FETCH_DELAY = env.int(
|
FEDERATION_OBJECT_FETCH_DELAY = env.int(
|
||||||
"FEDERATION_OBJECT_FETCH_DELAY", default=60 * 24 * 3
|
"FEDERATION_OBJECT_FETCH_DELAY", default=60 * 24 * 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MODERATION_EMAIL_NOTIFICATIONS_ENABLED = env.bool(
|
||||||
|
"MODERATION_EMAIL_NOTIFICATIONS_ENABLED", default=True
|
||||||
|
)
|
||||||
|
|
|
@ -204,6 +204,10 @@ class Actor(models.Model):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ["domain", "preferred_username"]
|
unique_together = ["domain", "preferred_username"]
|
||||||
|
verbose_name = "Account"
|
||||||
|
|
||||||
|
def get_moderation_url(self):
|
||||||
|
return "/manage/moderation/accounts/{}".format(self.full_username)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webfinger_subject(self):
|
def webfinger_subject(self):
|
||||||
|
|
|
@ -14,6 +14,7 @@ from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.playlists import models as playlists_models
|
from funkwhale_api.playlists import models as playlists_models
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import tasks
|
||||||
|
|
||||||
|
|
||||||
class FilteredArtistSerializer(serializers.ModelSerializer):
|
class FilteredArtistSerializer(serializers.ModelSerializer):
|
||||||
|
@ -257,4 +258,6 @@ class ReportSerializer(serializers.ModelSerializer):
|
||||||
== settings.FEDERATION_HOSTNAME
|
== settings.FEDERATION_HOSTNAME
|
||||||
)
|
)
|
||||||
validated_data["target_owner"] = get_target_owner(validated_data["target"])
|
validated_data["target_owner"] = get_target_owner(validated_data["target"])
|
||||||
return super().create(validated_data)
|
r = super().create(validated_data)
|
||||||
|
tasks.signals.report_created.send(sender=None, report=r)
|
||||||
|
return r
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import django.dispatch
|
||||||
|
|
||||||
|
report_created = django.dispatch.Signal(providing_args=["report"])
|
|
@ -0,0 +1,118 @@
|
||||||
|
import logging
|
||||||
|
from django.core import mail
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from funkwhale_api.common import channels
|
||||||
|
from funkwhale_api.common import utils
|
||||||
|
from funkwhale_api.taskapp import celery
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
|
from funkwhale_api.users import models as users_models
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import signals
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.report_created)
|
||||||
|
def broadcast_report_created(report, **kwargs):
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
channels.group_send(
|
||||||
|
"admin.moderation",
|
||||||
|
{
|
||||||
|
"type": "event.send",
|
||||||
|
"text": "",
|
||||||
|
"data": {
|
||||||
|
"type": "report.created",
|
||||||
|
"report": serializers.ReportSerializer(report).data,
|
||||||
|
"unresolved_count": models.Report.objects.filter(
|
||||||
|
is_handled=False
|
||||||
|
).count(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(signals.report_created)
|
||||||
|
def trigger_moderator_email(report, **kwargs):
|
||||||
|
if settings.MODERATION_EMAIL_NOTIFICATIONS_ENABLED:
|
||||||
|
utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
|
||||||
|
@celery.require_instance(
|
||||||
|
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
|
||||||
|
)
|
||||||
|
def send_new_report_email_to_moderators(report):
|
||||||
|
moderators = users_models.User.objects.filter(
|
||||||
|
is_active=True, permission_moderation=True
|
||||||
|
)
|
||||||
|
if not moderators:
|
||||||
|
# we fallback on superusers
|
||||||
|
moderators = users_models.User.objects.filter(is_superuser=True)
|
||||||
|
moderators = sorted(moderators, key=lambda m: m.pk)
|
||||||
|
subject = "[{} moderation - {}] New report from {}".format(
|
||||||
|
settings.FUNKWHALE_HOSTNAME,
|
||||||
|
report.get_type_display(),
|
||||||
|
report.submitter.full_username if report.submitter else report.submitter_email,
|
||||||
|
)
|
||||||
|
detail_url = federation_utils.full_url(
|
||||||
|
"/manage/moderation/reports/{}".format(report.uuid)
|
||||||
|
)
|
||||||
|
unresolved_reports_url = federation_utils.full_url(
|
||||||
|
"/manage/moderation/reports?q=resolved:no"
|
||||||
|
)
|
||||||
|
unresolved_reports = models.Report.objects.filter(is_handled=False).count()
|
||||||
|
body = [
|
||||||
|
'{} just submitted a report in the "{}" category.'.format(
|
||||||
|
report.submitter.full_username
|
||||||
|
if report.submitter
|
||||||
|
else report.submitter_email,
|
||||||
|
report.get_type_display(),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"Reported object: {} - {}".format(
|
||||||
|
report.target._meta.verbose_name.title(), str(report.target)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if hasattr(report.target, "get_absolute_url"):
|
||||||
|
body.append(
|
||||||
|
"Open public page: {}".format(
|
||||||
|
federation_utils.full_url(report.target.get_absolute_url())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if hasattr(report.target, "get_moderation_url"):
|
||||||
|
body.append(
|
||||||
|
"Open moderation page: {}".format(
|
||||||
|
federation_utils.full_url(report.target.get_moderation_url())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if report.summary:
|
||||||
|
body += ["", "Report content:", "", report.summary]
|
||||||
|
|
||||||
|
body += [
|
||||||
|
"",
|
||||||
|
"- To handle this report, please visit {}".format(detail_url),
|
||||||
|
"- To view all unresolved reports (currently {}), please visit {}".format(
|
||||||
|
unresolved_reports, unresolved_reports_url
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
"—",
|
||||||
|
"",
|
||||||
|
"You are receiving this email because you are a moderator for {}.".format(
|
||||||
|
settings.FUNKWHALE_HOSTNAME
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for moderator in moderators:
|
||||||
|
if not moderator.email:
|
||||||
|
logger.warning("Moderator %s has no email configured", moderator.username)
|
||||||
|
continue
|
||||||
|
mail.send_mail(
|
||||||
|
subject,
|
||||||
|
message="\n".join(body),
|
||||||
|
recipient_list=[moderator.email],
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
)
|
|
@ -217,6 +217,12 @@ class Artist(APIModelMixin):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return "/library/artists/{}".format(self.pk)
|
||||||
|
|
||||||
|
def get_moderation_url(self):
|
||||||
|
return "/manage/library/artists/{}".format(self.pk)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create_from_name(cls, name, **kwargs):
|
def get_or_create_from_name(cls, name, **kwargs):
|
||||||
kwargs.update({"name": name})
|
kwargs.update({"name": name})
|
||||||
|
@ -356,6 +362,12 @@ class Album(APIModelMixin):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return "/library/albums/{}".format(self.pk)
|
||||||
|
|
||||||
|
def get_moderation_url(self):
|
||||||
|
return "/manage/library/albums/{}".format(self.pk)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cover_path(self):
|
def cover_path(self):
|
||||||
if not self.cover:
|
if not self.cover:
|
||||||
|
@ -488,6 +500,12 @@ class Track(APIModelMixin):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return "/library/tracks/{}".format(self.pk)
|
||||||
|
|
||||||
|
def get_moderation_url(self):
|
||||||
|
return "/manage/library/tracks/{}".format(self.pk)
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
try:
|
try:
|
||||||
self.artist
|
self.artist
|
||||||
|
@ -1051,6 +1069,12 @@ class Library(federation_models.FederationMixin):
|
||||||
uploads_count = models.PositiveIntegerField(default=0)
|
uploads_count = models.PositiveIntegerField(default=0)
|
||||||
objects = LibraryQuerySet.as_manager()
|
objects = LibraryQuerySet.as_manager()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_moderation_url(self):
|
||||||
|
return "/manage/library/libraries/{}".format(self.uuid)
|
||||||
|
|
||||||
def get_federation_id(self):
|
def get_federation_id(self):
|
||||||
return federation_utils.full_url(
|
return federation_utils.full_url(
|
||||||
reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
|
reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
|
||||||
|
|
|
@ -69,6 +69,9 @@ class Playlist(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return "/library/playlists/{}".format(self.pk)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def insert(self, plt, index=None, allow_duplicates=True):
|
def insert(self, plt, index=None, allow_duplicates=True):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -232,8 +232,13 @@ class User(AbstractUser):
|
||||||
|
|
||||||
def get_channels_groups(self):
|
def get_channels_groups(self):
|
||||||
groups = ["imports", "inbox"]
|
groups = ["imports", "inbox"]
|
||||||
|
groups = ["user.{}.{}".format(self.pk, g) for g in groups]
|
||||||
|
|
||||||
return ["user.{}.{}".format(self.pk, g) for g in groups]
|
for permission, value in self.all_permissions.items():
|
||||||
|
if value:
|
||||||
|
groups.append("admin.{}".format(permission))
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
def full_username(self):
|
def full_username(self):
|
||||||
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
|
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
|
||||||
|
|
|
@ -15,3 +15,34 @@ def test_mutation_fid_is_populated(factories, model, factory_args, namespace):
|
||||||
assert instance.fid == federation_utils.full_url(
|
assert instance.fid == federation_utils.full_url(
|
||||||
reverse(namespace, kwargs={"uuid": instance.uuid})
|
reverse(namespace, kwargs={"uuid": instance.uuid})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory_name, expected",
|
||||||
|
[
|
||||||
|
("music.Artist", "/library/artists/{obj.pk}"),
|
||||||
|
("music.Album", "/library/albums/{obj.pk}"),
|
||||||
|
("music.Track", "/library/tracks/{obj.pk}"),
|
||||||
|
("playlists.Playlist", "/library/playlists/{obj.pk}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_absolute_url(factory_name, factories, expected):
|
||||||
|
obj = factories[factory_name]()
|
||||||
|
|
||||||
|
assert obj.get_absolute_url() == expected.format(obj=obj)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory_name, expected",
|
||||||
|
[
|
||||||
|
("music.Artist", "/manage/library/artists/{obj.pk}"),
|
||||||
|
("music.Album", "/manage/library/albums/{obj.pk}"),
|
||||||
|
("music.Track", "/manage/library/tracks/{obj.pk}"),
|
||||||
|
("music.Library", "/manage/library/libraries/{obj.uuid}"),
|
||||||
|
("federation.Actor", "/manage/moderation/accounts/{obj.full_username}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_moderation_url(factory_name, factories, expected):
|
||||||
|
obj = factories[factory_name]()
|
||||||
|
|
||||||
|
assert obj.get_moderation_url() == expected.format(obj=obj)
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
from funkwhale_api.moderation import serializers
|
from funkwhale_api.moderation import serializers
|
||||||
|
from funkwhale_api.moderation import signals
|
||||||
|
|
||||||
|
|
||||||
def test_user_filter_serializer_repr(factories):
|
def test_user_filter_serializer_repr(factories):
|
||||||
|
@ -225,3 +226,32 @@ def test_report_serializer_save_unauthenticated_validation(
|
||||||
payload["target"] = target_data
|
payload["target"] = target_data
|
||||||
serializer = serializers.ReportSerializer(data=payload, context=context)
|
serializer = serializers.ReportSerializer(data=payload, context=context)
|
||||||
assert serializer.is_valid() is is_valid
|
assert serializer.is_valid() is is_valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_create_send_websocket_event(factories, mocker):
|
||||||
|
target = factories["music.Artist"]()
|
||||||
|
group_send = mocker.patch("funkwhale_api.common.channels.group_send")
|
||||||
|
report_created = mocker.spy(signals.report_created, "send")
|
||||||
|
payload = {
|
||||||
|
"summary": "Report content",
|
||||||
|
"type": "illegal_content",
|
||||||
|
"target": {"type": "artist", "id": target.pk},
|
||||||
|
"submitter_email": "test@submitter.example",
|
||||||
|
}
|
||||||
|
serializer = serializers.ReportSerializer(data=payload)
|
||||||
|
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
report = serializer.save()
|
||||||
|
report_created.assert_called_once_with(sender=None, report=report)
|
||||||
|
group_send.assert_called_with(
|
||||||
|
"admin.moderation",
|
||||||
|
{
|
||||||
|
"type": "event.send",
|
||||||
|
"text": "",
|
||||||
|
"data": {
|
||||||
|
"type": "report.created",
|
||||||
|
"report": serializer.data,
|
||||||
|
"unresolved_count": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
|
|
||||||
|
from funkwhale_api.moderation import tasks
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_created_signal_calls_send_new_report_mail(factories, mocker):
|
||||||
|
report = factories["moderation.Report"]()
|
||||||
|
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||||
|
tasks.signals.report_created.send(sender=None, report=report)
|
||||||
|
on_commit.assert_called_once_with(
|
||||||
|
tasks.send_new_report_email_to_moderators.delay, report_id=report.pk
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_created_signal_sends_email_to_mods(factories, mailoutbox, settings):
|
||||||
|
mod1 = factories["users.User"](permission_moderation=True)
|
||||||
|
mod2 = factories["users.User"](permission_moderation=True)
|
||||||
|
# inactive, so no email
|
||||||
|
factories["users.User"](permission_moderation=True, is_active=False)
|
||||||
|
|
||||||
|
report = factories["moderation.Report"]()
|
||||||
|
|
||||||
|
tasks.send_new_report_email_to_moderators(report_id=report.pk)
|
||||||
|
|
||||||
|
detail_url = federation_utils.full_url(
|
||||||
|
"/manage/moderation/reports/{}".format(report.uuid)
|
||||||
|
)
|
||||||
|
unresolved_reports_url = federation_utils.full_url(
|
||||||
|
"/manage/moderation/reports?q=resolved:no"
|
||||||
|
)
|
||||||
|
for i, mod in enumerate([mod1, mod2]):
|
||||||
|
m = mailoutbox[i]
|
||||||
|
assert m.subject == "[{} moderation - {}] New report from {}".format(
|
||||||
|
settings.FUNKWHALE_HOSTNAME,
|
||||||
|
report.get_type_display(),
|
||||||
|
report.submitter.full_username,
|
||||||
|
)
|
||||||
|
assert report.summary in m.body
|
||||||
|
assert report.target._meta.verbose_name.title() in m.body
|
||||||
|
assert str(report.target) in m.body
|
||||||
|
assert report.target.get_absolute_url() in m.body
|
||||||
|
assert report.target.get_moderation_url() in m.body
|
||||||
|
assert detail_url in m.body
|
||||||
|
assert unresolved_reports_url in m.body
|
||||||
|
assert list(m.to) == [mod.email]
|
|
@ -176,11 +176,12 @@ def test_creating_actor_from_user(factories, settings):
|
||||||
|
|
||||||
|
|
||||||
def test_get_channels_groups(factories):
|
def test_get_channels_groups(factories):
|
||||||
user = factories["users.User"]()
|
user = factories["users.User"](permission_library=True)
|
||||||
|
|
||||||
assert user.get_channels_groups() == [
|
assert user.get_channels_groups() == [
|
||||||
"user.{}.imports".format(user.pk),
|
"user.{}.imports".format(user.pk),
|
||||||
"user.{}.inbox".format(user.pk),
|
"user.{}.inbox".format(user.pk),
|
||||||
|
"admin.library",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,11 @@ export default {
|
||||||
id: 'sidebarReviewEditCount',
|
id: 'sidebarReviewEditCount',
|
||||||
handler: this.incrementReviewEditCountInSidebar
|
handler: this.incrementReviewEditCountInSidebar
|
||||||
})
|
})
|
||||||
|
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||||
|
eventName: 'report.created',
|
||||||
|
id: 'sidebarPendingReviewReportCount',
|
||||||
|
handler: this.incrementPendingReviewReportsCountInSidebar
|
||||||
|
})
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
let self = this
|
let self = this
|
||||||
|
@ -133,6 +138,10 @@ export default {
|
||||||
eventName: 'mutation.updated',
|
eventName: 'mutation.updated',
|
||||||
id: 'sidebarReviewEditCount',
|
id: 'sidebarReviewEditCount',
|
||||||
})
|
})
|
||||||
|
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||||
|
eventName: 'mutation.updated',
|
||||||
|
id: 'sidebarPendingReviewReportCount',
|
||||||
|
})
|
||||||
this.disconnect()
|
this.disconnect()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -142,6 +151,10 @@ export default {
|
||||||
incrementReviewEditCountInSidebar (event) {
|
incrementReviewEditCountInSidebar (event) {
|
||||||
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count})
|
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count})
|
||||||
},
|
},
|
||||||
|
incrementPendingReviewReportsCountInSidebar (event) {
|
||||||
|
console.log('HELLO', event)
|
||||||
|
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count})
|
||||||
|
},
|
||||||
fetchNodeInfo () {
|
fetchNodeInfo () {
|
||||||
let self = this
|
let self = this
|
||||||
axios.get('instance/nodeinfo/2.0/').then(response => {
|
axios.get('instance/nodeinfo/2.0/').then(response => {
|
||||||
|
|
|
@ -22,6 +22,7 @@ export default {
|
||||||
'import.status_updated': {},
|
'import.status_updated': {},
|
||||||
'mutation.created': {},
|
'mutation.created': {},
|
||||||
'mutation.updated': {},
|
'mutation.updated': {},
|
||||||
|
'report.created': {},
|
||||||
},
|
},
|
||||||
pageTitle: null
|
pageTitle: null
|
||||||
},
|
},
|
||||||
|
|
|
@ -135,7 +135,10 @@ export default {
|
||||||
axios.get('manage/moderation/reports/', {params: params}).then((response) => {
|
axios.get('manage/moderation/reports/', {params: params}).then((response) => {
|
||||||
self.result = response.data
|
self.result = response.data
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
// self.fetchTargets()
|
if (self.search.query === 'resolved:no') {
|
||||||
|
console.log('Refreshing sidebar notifications')
|
||||||
|
self.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: response.data.count})
|
||||||
|
}
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
|
|
Loading…
Reference in New Issue