From c9a9615be8eeeac4f03abdb6e6048e2e5cc0d7e5 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 13 Sep 2019 06:09:48 +0200 Subject: [PATCH] See #890: web UI and email notifications on new reports --- api/config/settings/common.py | 4 + api/funkwhale_api/federation/models.py | 4 + api/funkwhale_api/moderation/serializers.py | 5 +- api/funkwhale_api/moderation/signals.py | 3 + api/funkwhale_api/moderation/tasks.py | 118 ++++++++++++++++++ api/funkwhale_api/music/models.py | 24 ++++ api/funkwhale_api/playlists/models.py | 3 + api/funkwhale_api/users/models.py | 7 +- api/tests/common/test_models.py | 31 +++++ api/tests/moderation/test_serializers.py | 30 +++++ api/tests/moderation/test_tasks.py | 45 +++++++ api/tests/users/test_models.py | 3 +- front/src/App.vue | 13 ++ front/src/store/ui.js | 1 + .../views/admin/moderation/ReportsList.vue | 5 +- 15 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 api/funkwhale_api/moderation/signals.py create mode 100644 api/funkwhale_api/moderation/tasks.py create mode 100644 api/tests/moderation/test_tasks.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6d1f4a9f5..86950d726 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -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", default=60 * 24 * 3 ) + +MODERATION_EMAIL_NOTIFICATIONS_ENABLED = env.bool( + "MODERATION_EMAIL_NOTIFICATIONS_ENABLED", default=True +) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index fa5050e34..304b94fad 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -204,6 +204,10 @@ class Actor(models.Model): class Meta: unique_together = ["domain", "preferred_username"] + verbose_name = "Account" + + def get_moderation_url(self): + return "/manage/moderation/accounts/{}".format(self.full_username) @property def webfinger_subject(self): diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index fb87f5b9d..81e5846bb 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -14,6 +14,7 @@ from funkwhale_api.music import models as music_models from funkwhale_api.playlists import models as playlists_models from . import models +from . import tasks class FilteredArtistSerializer(serializers.ModelSerializer): @@ -257,4 +258,6 @@ class ReportSerializer(serializers.ModelSerializer): == settings.FEDERATION_HOSTNAME ) 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 diff --git a/api/funkwhale_api/moderation/signals.py b/api/funkwhale_api/moderation/signals.py new file mode 100644 index 000000000..16be236e0 --- /dev/null +++ b/api/funkwhale_api/moderation/signals.py @@ -0,0 +1,3 @@ +import django.dispatch + +report_created = django.dispatch.Signal(providing_args=["report"]) diff --git a/api/funkwhale_api/moderation/tasks.py b/api/funkwhale_api/moderation/tasks.py new file mode 100644 index 000000000..2942e3d03 --- /dev/null +++ b/api/funkwhale_api/moderation/tasks.py @@ -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, + ) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index fc4118a98..02c4f4434 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -217,6 +217,12 @@ class Artist(APIModelMixin): def __str__(self): 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 def get_or_create_from_name(cls, name, **kwargs): kwargs.update({"name": name}) @@ -356,6 +362,12 @@ class Album(APIModelMixin): def __str__(self): 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 def cover_path(self): if not self.cover: @@ -488,6 +500,12 @@ class Track(APIModelMixin): def __str__(self): 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): try: self.artist @@ -1051,6 +1069,12 @@ class Library(federation_models.FederationMixin): uploads_count = models.PositiveIntegerField(default=0) 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): return federation_utils.full_url( reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid}) diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index 15332c75a..37d498c4f 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -69,6 +69,9 @@ class Playlist(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return "/library/playlists/{}".format(self.pk) + @transaction.atomic def insert(self, plt, index=None, allow_duplicates=True): """ diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index e0958bc82..dd181f7bf 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -232,8 +232,13 @@ class User(AbstractUser): def get_channels_groups(self): 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): return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME) diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py index 25c9befda..a2ea89ef2 100644 --- a/api/tests/common/test_models.py +++ b/api/tests/common/test_models.py @@ -15,3 +15,34 @@ def test_mutation_fid_is_populated(factories, model, factory_args, namespace): assert instance.fid == federation_utils.full_url( 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) diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py index 041a8f274..01cb323ee 100644 --- a/api/tests/moderation/test_serializers.py +++ b/api/tests/moderation/test_serializers.py @@ -7,6 +7,7 @@ from django.core.serializers.json import DjangoJSONEncoder from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models from funkwhale_api.moderation import serializers +from funkwhale_api.moderation import signals def test_user_filter_serializer_repr(factories): @@ -225,3 +226,32 @@ def test_report_serializer_save_unauthenticated_validation( payload["target"] = target_data serializer = serializers.ReportSerializer(data=payload, context=context) 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, + }, + }, + ) diff --git a/api/tests/moderation/test_tasks.py b/api/tests/moderation/test_tasks.py new file mode 100644 index 000000000..18e031fd8 --- /dev/null +++ b/api/tests/moderation/test_tasks.py @@ -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] diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 1b185e55f..c98472a27 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -176,11 +176,12 @@ def test_creating_actor_from_user(factories, settings): def test_get_channels_groups(factories): - user = factories["users.User"]() + user = factories["users.User"](permission_library=True) assert user.get_channels_groups() == [ "user.{}.imports".format(user.pk), "user.{}.inbox".format(user.pk), + "admin.library", ] diff --git a/front/src/App.vue b/front/src/App.vue index 089596299..9881554f9 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -107,6 +107,11 @@ export default { id: 'sidebarReviewEditCount', handler: this.incrementReviewEditCountInSidebar }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'report.created', + id: 'sidebarPendingReviewReportCount', + handler: this.incrementPendingReviewReportsCountInSidebar + }) }, mounted () { let self = this @@ -133,6 +138,10 @@ export default { eventName: 'mutation.updated', id: 'sidebarReviewEditCount', }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'mutation.updated', + id: 'sidebarPendingReviewReportCount', + }) this.disconnect() }, methods: { @@ -142,6 +151,10 @@ export default { incrementReviewEditCountInSidebar (event) { 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 () { let self = this axios.get('instance/nodeinfo/2.0/').then(response => { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index fccbf9348..989e8b2ad 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -22,6 +22,7 @@ export default { 'import.status_updated': {}, 'mutation.created': {}, 'mutation.updated': {}, + 'report.created': {}, }, pageTitle: null }, diff --git a/front/src/views/admin/moderation/ReportsList.vue b/front/src/views/admin/moderation/ReportsList.vue index 7313828f2..9cb4af7f7 100644 --- a/front/src/views/admin/moderation/ReportsList.vue +++ b/front/src/views/admin/moderation/ReportsList.vue @@ -135,7 +135,10 @@ export default { axios.get('manage/moderation/reports/', {params: params}).then((response) => { self.result = response.data 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 => { self.isLoading = false self.errors = error.backendErrors