Commit c9a9615b authored by Eliot Berriot's avatar Eliot Berriot 💬

See #890: web UI and email notifications on new reports

parent 368b70d3
......@@ -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
)
......@@ -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):
......
......@@ -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
import django.dispatch
report_created = django.dispatch.Signal(providing_args=["report"])
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):
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})
......
......@@ -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):
"""
......
......@@ -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)
......
......@@ -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)
......@@ -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,
},
},
)
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):
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",
]
......
......@@ -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 => {
......
......@@ -22,6 +22,7 @@ export default {
'import.status_updated': {},
'mutation.created': {},
'mutation.updated': {},
'report.created': {},
},
pageTitle: null
},
......
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment