From c9a9615be8eeeac4f03abdb6e6048e2e5cc0d7e5 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
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 6d1f4a9f56..86950d726c 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 fa5050e34b..304b94fad7 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 fb87f5b9d4..81e5846bb4 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 0000000000..16be236e0c
--- /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 0000000000..2942e3d03b
--- /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 fc4118a98d..02c4f4434b 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 15332c75a2..37d498c4f0 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 e0958bc823..dd181f7bfe 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 25c9befda8..a2ea89ef2a 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 041a8f2743..01cb323eee 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 0000000000..18e031fd8a
--- /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 1b185e55f8..c98472a278 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 0895962994..9881554f9f 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 fccbf93486..989e8b2adf 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 7313828f21..9cb4af7f74 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
-- 
GitLab