diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index d86283fe5..f206b626e 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -57,6 +57,64 @@ class SmartSearchFilter(django_filters.CharFilter): return search.apply(qs, cleaned) +def get_generic_filter_query(value, relation_name, choices): + parts = value.split(":", 1) + type = parts[0] + try: + conf = choices[type] + except KeyError: + raise forms.ValidationError("Invalid type") + related_queryset = conf["queryset"] + related_model = related_queryset.model + filter_query = models.Q( + **{ + "{}_content_type__app_label".format( + relation_name + ): related_model._meta.app_label, + "{}_content_type__model".format( + relation_name + ): related_model._meta.model_name, + } + ) + if len(parts) > 1: + id_attr = conf.get("id_attr", "id") + id_field = conf.get("id_field", serializers.IntegerField(min_value=1)) + try: + id_value = parts[1] + id_value = id_field.to_internal_value(id_value) + except (TypeError, KeyError, serializers.ValidationError): + raise forms.ValidationError("Invalid id") + query_getter = conf.get( + "get_query", lambda attr, value: models.Q(**{attr: value}) + ) + obj_query = query_getter(id_attr, id_value) + try: + obj = related_queryset.get(obj_query) + except related_queryset.model.DoesNotExist: + raise forms.ValidationError("Invalid object") + filter_query &= models.Q(**{"{}_id".format(relation_name): obj.id}) + + return filter_query + + +class GenericRelationFilter(django_filters.CharFilter): + def __init__(self, relation_name, choices, *args, **kwargs): + self.relation_name = relation_name + self.choices = choices + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if not value: + return qs + try: + filter_query = get_generic_filter_query( + value, relation_name=self.relation_name, choices=self.choices + ) + except forms.ValidationError: + return qs.none() + return qs.filter(filter_query) + + class GenericRelation(serializers.JSONField): def __init__(self, choices, *args, **kwargs): self.choices = choices @@ -68,14 +126,16 @@ class GenericRelation(serializers.JSONField): return type = None id = None + id_attr = None for key, choice in self.choices.items(): if isinstance(value, choice["queryset"].model): type = key - id = getattr(value, choice.get("id_attr", "id")) + id_attr = choice.get("id_attr", "id") + id = getattr(value, id_attr) break if type: - return {"type": type, "id": id} + return {"type": type, id_attr: id} def to_internal_value(self, v): v = super().to_internal_value(v) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 364a1fba1..feca948bb 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -15,7 +15,7 @@ class NoneObject(object): NONE = NoneObject() -NULL_BOOLEAN_CHOICES = [ +BOOLEAN_CHOICES = [ (True, True), ("true", True), ("True", True), @@ -26,6 +26,8 @@ NULL_BOOLEAN_CHOICES = [ ("False", False), ("0", False), ("no", False), +] +NULL_BOOLEAN_CHOICES = BOOLEAN_CHOICES + [ ("None", NONE), ("none", NONE), ("Null", NONE), @@ -76,10 +78,26 @@ def clean_null_boolean_filter(v): return v +def clean_boolean_filter(v): + return CoerceChoiceField(choices=BOOLEAN_CHOICES).clean(v) + + def get_null_boolean_filter(name): return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} +def get_boolean_filter(name): + return {"handler": lambda v: Q(**{name: clean_boolean_filter(v)})} + + +def get_generic_relation_filter(relation_name, choices): + return { + "handler": lambda v: fields.get_generic_filter_query( + v, relation_name=relation_name, choices=choices + ) + } + + class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): def valid_value(self, value): return True @@ -142,7 +160,7 @@ class MutationFilter(filters.FilterSet): "domain": {"to": "created_by__domain__name__iexact"}, "is_approved": get_null_boolean_filter("is_approved"), "target": {"handler": filter_target}, - "is_applied": {"to": "is_applied"}, + "is_applied": get_boolean_filter("is_applied"), }, ) ) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 6a6e7b99d..5791afba3 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -5,11 +5,14 @@ import django_filters from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import utils as federation_utils from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.moderation import serializers as moderation_serializers +from funkwhale_api.moderation import utils as moderation_utils from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models from funkwhale_api.tags import models as tags_models @@ -26,7 +29,7 @@ class ActorField(forms.CharField): def get_actor_filter(actor_field): def handler(v): - federation_utils.get_actor_from_username_data_query(actor_field, v) + return federation_utils.get_actor_from_username_data_query(actor_field, v) return {"field": ActorField(), "handler": handler} @@ -322,6 +325,10 @@ class ManageInstancePolicyFilterSet(filters.FilterSet): ] ) + target_domain = filters.CharFilter("target_domain__name") + target_account_domain = filters.CharFilter("target_actor__domain__name") + target_account_username = filters.CharFilter("target_actor__preferred_username") + class Meta: model = moderation_models.InstancePolicy fields = [ @@ -330,6 +337,9 @@ class ManageInstancePolicyFilterSet(filters.FilterSet): "silence_activity", "silence_notifications", "reject_media", + "target_domain", + "target_account_domain", + "target_account_username", ] @@ -339,3 +349,48 @@ class ManageTagFilterSet(filters.FilterSet): class Meta: model = tags_models.Tag fields = ["q"] + + +class ManageReportFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={"summary": {"to": "summary"}}, + filter_fields={ + "uuid": {"to": "uuid"}, + "id": {"to": "id"}, + "resolved": common_filters.get_boolean_filter("is_handled"), + "domain": {"to": "target_owner__domain_id"}, + "category": {"to": "type"}, + "submitter": get_actor_filter("submitter"), + "assigned_to": get_actor_filter("assigned_to"), + "target_owner": get_actor_filter("target_owner"), + "submitter_email": {"to": "submitter_email"}, + "target": common_filters.get_generic_relation_filter( + "target", moderation_serializers.TARGET_CONFIG + ), + }, + ) + ) + + class Meta: + model = moderation_models.Report + fields = ["q", "is_handled", "type", "submitter_email"] + + +class ManageNoteFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={"summary": {"to": "summary"}}, + filter_fields={ + "uuid": {"to": "uuid"}, + "author": get_actor_filter("author"), + "target": common_filters.get_generic_relation_filter( + "target", moderation_utils.NOTE_TARGET_FIELDS + ), + }, + ) + ) + + class Meta: + model = moderation_models.Note + fields = ["q"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 8d4b01b05..48ea1dc50 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -1,13 +1,17 @@ +from django.conf import settings from django.db import transaction from rest_framework import serializers +from funkwhale_api.common import fields as common_fields from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import fields as federation_fields from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.moderation import serializers as moderation_serializers +from funkwhale_api.moderation import utils as moderation_utils from funkwhale_api.music import models as music_models from funkwhale_api.music import serializers as music_serializers from funkwhale_api.tags import models as tags_models @@ -182,6 +186,8 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer): class ManageBaseActorSerializer(serializers.ModelSerializer): + is_local = serializers.SerializerMethodField() + class Meta: model = federation_models.Actor fields = [ @@ -200,9 +206,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer): "outbox_url", "shared_inbox_url", "manually_approves_followers", + "is_local", ] read_only_fields = ["creation_date", "instance_policy"] + def get_is_local(self, o): + return o.domain_id == settings.FEDERATION_HOSTNAME + class ManageActorSerializer(ManageBaseActorSerializer): uploads_count = serializers.SerializerMethodField() @@ -629,3 +639,64 @@ class ManageTagActionSerializer(common_serializers.ActionSerializer): @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class ManageBaseNoteSerializer(serializers.ModelSerializer): + author = ManageBaseActorSerializer(required=False, read_only=True) + + class Meta: + model = moderation_models.Note + fields = ["id", "uuid", "creation_date", "summary", "author"] + read_only_fields = ["uuid", "creation_date", "author"] + + +class ManageNoteSerializer(ManageBaseNoteSerializer): + target = common_fields.GenericRelation(moderation_utils.NOTE_TARGET_FIELDS) + + class Meta(ManageBaseNoteSerializer.Meta): + fields = ManageBaseNoteSerializer.Meta.fields + ["target"] + + +class ManageReportSerializer(serializers.ModelSerializer): + assigned_to = ManageBaseActorSerializer() + target_owner = ManageBaseActorSerializer() + submitter = ManageBaseActorSerializer() + target = moderation_serializers.TARGET_FIELD + notes = serializers.SerializerMethodField() + + class Meta: + model = moderation_models.Report + fields = [ + "id", + "uuid", + "fid", + "creation_date", + "handled_date", + "summary", + "type", + "target", + "target_state", + "is_handled", + "assigned_to", + "target_owner", + "submitter", + "submitter_email", + "notes", + ] + read_only_fields = [ + "id", + "uuid", + "fid", + "submitter", + "submitter_email", + "creation_date", + "handled_date", + "target", + "target_state", + "target_owner", + "summary", + ] + + def get_notes(self, o): + notes = getattr(o, "_prefetched_notes", []) + return ManageBaseNoteSerializer(notes, many=True).data diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index b830f0023..36997b24a 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -17,6 +17,8 @@ moderation_router = routers.OptionalSlashRouter() moderation_router.register( r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" ) +moderation_router.register(r"reports", views.ManageReportViewSet, "reports") +moderation_router.register(r"notes", views.ManageNoteViewSet, "notes") users_router = routers.OptionalSlashRouter() users_router.register(r"users", views.ManageUserViewSet, "users") diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index ea3c33f80..8a4f91e77 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -459,6 +459,60 @@ class ManageInstancePolicyViewSet( serializer.save(actor=self.request.user.actor) +class ManageReportViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + moderation_models.Report.objects.all() + .order_by("-creation_date") + .select_related( + "submitter", "target_owner", "assigned_to", "target_content_type" + ) + .prefetch_related("target") + .prefetch_related( + Prefetch( + "notes", + queryset=moderation_models.Note.objects.order_by( + "creation_date" + ).select_related("author"), + to_attr="_prefetched_notes", + ) + ) + ) + serializer_class = serializers.ManageReportSerializer + filterset_class = filters.ManageReportFilterSet + required_scope = "instance:reports" + ordering_fields = ["id", "creation_date", "handled_date"] + + +class ManageNoteViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + moderation_models.Note.objects.all() + .order_by("-creation_date") + .select_related("author", "target_content_type") + .prefetch_related("target") + ) + serializer_class = serializers.ManageNoteSerializer + filterset_class = filters.ManageNoteFilterSet + required_scope = "instance:notes" + ordering_fields = ["id", "creation_date"] + + def perform_create(self, serializer): + author = self.request.user.actor + return serializer.save(author=author) + + class ManageTagViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/api/funkwhale_api/moderation/admin.py b/api/funkwhale_api/moderation/admin.py index 9f8340030..1a44ea283 100644 --- a/api/funkwhale_api/moderation/admin.py +++ b/api/funkwhale_api/moderation/admin.py @@ -30,6 +30,22 @@ class InstancePolicyAdmin(admin.ModelAdmin): list_select_related = True +@admin.register(models.Report) +class ReportAdmin(admin.ModelAdmin): + list_display = [ + "uuid", + "submitter", + "type", + "assigned_to", + "is_handled", + "creation_date", + "handled_date", + ] + list_filter = ["type", "is_handled"] + search_fields = ["summary"] + list_select_related = True + + @admin.register(models.UserFilter) class UserFilterAdmin(admin.ModelAdmin): list_display = ["uuid", "user", "target_artist", "creation_date"] diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py index 4bf7ce584..b426a6cea 100644 --- a/api/funkwhale_api/moderation/factories.py +++ b/api/funkwhale_api/moderation/factories.py @@ -5,6 +5,8 @@ from funkwhale_api.federation import factories as federation_factories from funkwhale_api.music import factories as music_factories from funkwhale_api.users import factories as users_factories +from . import serializers + @registry.register class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): @@ -39,10 +41,20 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ) +@registry.register +class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory): + author = factory.SubFactory(federation_factories.ActorFactory) + target = None + summary = factory.Faker("paragraph") + + class Meta: + model = "moderation.Note" + + @registry.register class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): submitter = factory.SubFactory(federation_factories.ActorFactory) - target = None + target = factory.SubFactory(music_factories.ArtistFactory) summary = factory.Faker("paragraph") type = "other" @@ -51,3 +63,13 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class Params: anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) + assigned = factory.Trait( + assigned_to=factory.SubFactory(federation_factories.ActorFactory) + ) + + @factory.post_generation + def _set_target_owner(self, create, extracted, **kwargs): + if not self.target: + return + + self.target_owner = serializers.get_target_owner(self.target) diff --git a/api/funkwhale_api/moderation/migrations/0004_note.py b/api/funkwhale_api/moderation/migrations/0004_note.py new file mode 100644 index 000000000..8e7454b69 --- /dev/null +++ b/api/funkwhale_api/moderation/migrations/0004_note.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.4 on 2019-08-29 09:08 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('federation', '0020_auto_20190730_0846'), + ('contenttypes', '0002_remove_content_type_name'), + ('moderation', '0003_report'), + ] + + operations = [ + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('summary', models.TextField(max_length=50000)), + ('target_id', models.IntegerField(null=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_notes', to='federation.Actor')), + ('target_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + ), + ] diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py index ccc891e79..5a4081b7b 100644 --- a/api/funkwhale_api/moderation/models.py +++ b/api/funkwhale_api/moderation/models.py @@ -1,11 +1,12 @@ import urllib.parse import uuid - -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField from django.db import models +from django.db.models.signals import pre_save +from django.dispatch import receiver from django.urls import reverse from django.utils import timezone @@ -147,6 +148,10 @@ class Report(federation_models.FederationMixin): # delete target_state = JSONField(null=True) + notes = GenericRelation( + "Note", content_type_field="target_content_type", object_id_field="target_id" + ) + def get_federation_id(self): if self.fid: return self.fid @@ -160,3 +165,26 @@ class Report(federation_models.FederationMixin): self.fid = self.get_federation_id() return super().save(**kwargs) + + +class Note(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True) + creation_date = models.DateTimeField(default=timezone.now) + summary = models.TextField(max_length=50000) + author = models.ForeignKey( + "federation.Actor", related_name="moderation_notes", on_delete=models.CASCADE + ) + + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, null=True, on_delete=models.CASCADE + ) + target = GenericForeignKey("target_content_type", "target_id") + + +@receiver(pre_save, sender=Report) +def set_handled_date(sender, instance, **kwargs): + if instance.is_handled is True and not instance.handled_date: + instance.handled_date = timezone.now() + elif not instance.is_handled: + instance.handled_date = None diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py index c52a9e913..fb87f5b9d 100644 --- a/api/funkwhale_api/moderation/serializers.py +++ b/api/funkwhale_api/moderation/serializers.py @@ -1,5 +1,9 @@ -import persisting_theory +import json +import urllib.parse +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +import persisting_theory from rest_framework import serializers from funkwhale_api.common import fields as common_fields @@ -117,7 +121,15 @@ class TrackStateSerializer(serializers.ModelSerializer): class LibraryStateSerializer(serializers.ModelSerializer): class Meta: model = music_models.Library - fields = ["id", "fid", "name", "description", "creation_date", "privacy_level"] + fields = [ + "id", + "uuid", + "fid", + "name", + "description", + "creation_date", + "privacy_level", + ] @state_serializers.register(name="playlists.Playlist") @@ -135,6 +147,7 @@ class ActorStateSerializer(serializers.ModelSerializer): "fid", "name", "preferred_username", + "full_username", "summary", "domain", "type", @@ -160,26 +173,28 @@ def get_target_owner(target): return mapping[target.__class__](target) +TARGET_CONFIG = { + "artist": {"queryset": music_models.Artist.objects.all()}, + "album": {"queryset": music_models.Album.objects.all()}, + "track": {"queryset": music_models.Track.objects.all()}, + "library": { + "queryset": music_models.Library.objects.all(), + "id_attr": "uuid", + "id_field": serializers.UUIDField(), + }, + "playlist": {"queryset": playlists_models.Playlist.objects.all()}, + "account": { + "queryset": federation_models.Actor.objects.all(), + "id_attr": "full_username", + "id_field": serializers.EmailField(), + "get_query": get_actor_query, + }, +} +TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG) + + class ReportSerializer(serializers.ModelSerializer): - target = common_fields.GenericRelation( - { - "artist": {"queryset": music_models.Artist.objects.all()}, - "album": {"queryset": music_models.Album.objects.all()}, - "track": {"queryset": music_models.Track.objects.all()}, - "library": { - "queryset": music_models.Library.objects.all(), - "id_attr": "uuid", - "id_field": serializers.UUIDField(), - }, - "playlist": {"queryset": playlists_models.Playlist.objects.all()}, - "account": { - "queryset": federation_models.Actor.objects.all(), - "id_attr": "full_username", - "id_field": serializers.EmailField(), - "get_query": get_actor_query, - }, - } - ) + target = TARGET_FIELD class Meta: model = models.Report @@ -225,5 +240,21 @@ class ReportSerializer(serializers.ModelSerializer): validated_data["target_state"] = target_state_serializer( validated_data["target"] ).data + # freeze target type/id in JSON so even if the corresponding object is deleted + # we can have the info and display it in the frontend + target_data = self.fields["target"].to_representation(validated_data["target"]) + validated_data["target_state"]["_target"] = json.loads( + json.dumps(target_data, cls=DjangoJSONEncoder) + ) + + if "fid" in validated_data["target_state"]: + validated_data["target_state"]["domain"] = urllib.parse.urlparse( + validated_data["target_state"]["fid"] + ).hostname + + validated_data["target_state"]["is_local"] = ( + validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME) + == settings.FEDERATION_HOSTNAME + ) validated_data["target_owner"] = get_target_owner(validated_data["target"]) return super().create(validated_data) diff --git a/api/funkwhale_api/moderation/utils.py b/api/funkwhale_api/moderation/utils.py new file mode 100644 index 000000000..d4a1b879a --- /dev/null +++ b/api/funkwhale_api/moderation/utils.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from funkwhale_api.federation import models as federation_models + +from . import models +from . import serializers as moderation_serializers + + +NOTE_TARGET_FIELDS = { + "report": { + "queryset": models.Report.objects.all(), + "id_attr": "uuid", + "id_field": serializers.UUIDField(), + }, + "account": { + "queryset": federation_models.Actor.objects.all(), + "id_attr": "full_username", + "id_field": serializers.EmailField(), + "get_query": moderation_serializers.get_actor_query, + }, +} diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 1e95aac2a..e0958bc82 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -46,6 +46,10 @@ PERMISSIONS_CONFIGURATION = { "write:instance:accounts", "read:instance:domains", "write:instance:domains", + "read:instance:reports", + "write:instance:reports", + "read:instance:notes", + "write:instance:notes", }, }, "library": { diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py index 8cf91192c..88b928c50 100644 --- a/api/funkwhale_api/users/oauth/scopes.py +++ b/api/funkwhale_api/users/oauth/scopes.py @@ -34,6 +34,8 @@ BASE_SCOPES = [ Scope("instance:accounts", "Access instance federated accounts"), Scope("instance:domains", "Access instance domains"), Scope("instance:policies", "Access instance moderation policies"), + Scope("instance:reports", "Access instance moderation reports"), + Scope("instance:notes", "Access instance moderation notes"), ] SCOPES = [ Scope("read", children=[s.copy("read") for s in BASE_SCOPES]), diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py index 21e85b700..2cc07f1b2 100644 --- a/api/tests/common/test_fields.py +++ b/api/tests/common/test_fields.py @@ -67,3 +67,39 @@ def test_generic_relation_field_validation_error(payload, expected_error, factor with pytest.raises(fields.serializers.ValidationError, match=expected_error): f.to_internal_value(payload) + + +def test_generic_relation_filter_target_type(factories): + user = factories["users.User"]() + note = factories["moderation.Note"](target=user) + factories["moderation.Note"](target=factories["music.Artist"]()) + f = fields.GenericRelationFilter( + "target", + { + "user": { + "queryset": user.__class__.objects.all(), + "id_attr": "username", + "id_field": fields.serializers.CharField(), + } + }, + ) + qs = f.filter(note.__class__.objects.all(), "user") + assert list(qs) == [note] + + +def test_generic_relation_filter_target_type_and_id(factories): + user = factories["users.User"]() + note = factories["moderation.Note"](target=user) + factories["moderation.Note"](target=factories["users.User"]()) + f = fields.GenericRelationFilter( + "target", + { + "user": { + "queryset": user.__class__.objects.all(), + "id_attr": "username", + "id_field": fields.serializers.CharField(), + } + }, + ) + qs = f.filter(note.__class__.objects.all(), "user:{}".format(user.username)) + assert list(qs) == [note] diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 16fd9ce58..d8a2ee8f9 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -87,6 +87,7 @@ def test_manage_actor_serializer(factories, now, to_api_date): "full_username": actor.full_username, "user": None, "instance_policy": None, + "is_local": False, } s = serializers.ManageActorSerializer(actor) @@ -521,3 +522,49 @@ def test_manage_tag_serializer(factories, to_api_date): s = serializers.ManageTagSerializer(tag) assert s.data == expected + + +def test_manage_report_serializer(factories, to_api_date): + artist = factories["music.Artist"](attributed=True) + report = factories["moderation.Report"]( + target=artist, target_state={"hello": "world"}, assigned=True + ) + expected = { + "id": report.id, + "uuid": str(report.uuid), + "fid": report.fid, + "creation_date": to_api_date(report.creation_date), + "handled_date": None, + "summary": report.summary, + "is_handled": report.is_handled, + "type": report.type, + "submitter_email": None, + "submitter": serializers.ManageBaseActorSerializer(report.submitter).data, + "assigned_to": serializers.ManageBaseActorSerializer(report.assigned_to).data, + "target": {"type": "artist", "id": artist.pk}, + "target_owner": serializers.ManageBaseActorSerializer( + artist.attributed_to + ).data, + "target_state": report.target_state, + "notes": [], + } + s = serializers.ManageReportSerializer(report) + + assert s.data == expected + + +def test_manage_note_serializer(factories, to_api_date): + actor = factories["federation.Actor"]() + note = factories["moderation.Note"](target=actor) + + expected = { + "id": note.id, + "uuid": str(note.uuid), + "summary": note.summary, + "creation_date": to_api_date(note.creation_date), + "author": serializers.ManageBaseActorSerializer(note.author).data, + "target": {"type": "account", "full_username": actor.full_username}, + } + s = serializers.ManageNoteSerializer(note) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index a164e4012..7f17fce11 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -391,6 +391,50 @@ def test_upload_delete(factories, superuser_api_client): assert response.status_code == 204 +def test_note_create(factories, superuser_api_client): + actor = superuser_api_client.user.create_actor() + target = factories["federation.Actor"]() + data = { + "summary": "Hello", + "target": {"type": "account", "full_username": target.full_username}, + } + url = reverse("api:v1:manage:moderation:notes-list") + response = superuser_api_client.post(url, data, format="json") + assert response.status_code == 201 + + note = actor.moderation_notes.latest("id") + assert note.target == target + assert response.data == serializers.ManageNoteSerializer(note).data + + +def test_note_list(factories, superuser_api_client, settings): + note = factories["moderation.Note"]() + url = reverse("api:v1:manage:moderation:notes-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0] == serializers.ManageNoteSerializer(note).data + + +def test_note_delete(factories, superuser_api_client): + note = factories["moderation.Note"]() + url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_note_detail(factories, superuser_api_client): + note = factories["moderation.Note"]() + url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data == serializers.ManageNoteSerializer(note).data + + def test_tag_detail(factories, superuser_api_client): tag = factories["tags.Tag"]() url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) @@ -417,3 +461,37 @@ def test_tag_delete(factories, superuser_api_client): response = superuser_api_client.delete(url) assert response.status_code == 204 + + +def test_report_detail(factories, superuser_api_client): + report = factories["moderation.Report"]() + url = reverse( + "api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid} + ) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["summary"] == report.summary + + +def test_report_list(factories, superuser_api_client, settings): + report = factories["moderation.Report"]() + url = reverse("api:v1:manage:moderation:reports-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["summary"] == report.summary + + +def test_report_update(factories, superuser_api_client): + report = factories["moderation.Report"]() + url = reverse( + "api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid} + ) + response = superuser_api_client.patch(url, {"is_handled": True}) + + assert response.status_code == 200 + report.refresh_from_db() + assert report.is_handled is True diff --git a/api/tests/moderation/test_models.py b/api/tests/moderation/test_models.py new file mode 100644 index 000000000..8c1de981f --- /dev/null +++ b/api/tests/moderation/test_models.py @@ -0,0 +1,22 @@ +def test_setting_report_handled_to_true_sets_handled_date(factories, now): + target = factories["music.Artist"]() + report = factories["moderation.Report"](target=target) + + assert report.is_handled is False + assert report.handled_date is None + + report.is_handled = True + report.save() + + assert report.handled_date == now + + +def test_setting_report_handled_to_false_sets_handled_date_to_null(factories, now): + target = factories["music.Artist"]() + report = factories["moderation.Report"]( + target=target, is_handled=True, handled_date=now + ) + report.is_handled = False + report.save() + + assert report.handled_date is None diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py index 37c95c78d..041a8f274 100644 --- a/api/tests/moderation/test_serializers.py +++ b/api/tests/moderation/test_serializers.py @@ -1,4 +1,8 @@ +import json import pytest +import urllib.parse + +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 @@ -41,7 +45,6 @@ def test_user_filter_serializer_save(factories): ("music.Album", "album", "id", serializers.AlbumStateSerializer), ("music.Track", "track", "id", serializers.TrackStateSerializer), ("music.Library", "library", "uuid", serializers.LibraryStateSerializer), - ("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer), ( "federation.Actor", "account", @@ -50,8 +53,8 @@ def test_user_filter_serializer_save(factories): ), ], ) -def test_report_serializer_save( - factory_name, target_type, id_field, state_serializer, factories, mocker +def test_report_federated_entity_serializer_save( + factory_name, target_type, id_field, state_serializer, factories, mocker, settings ): target = factories[factory_name]() target_owner = factories["federation.Actor"]() @@ -72,10 +75,58 @@ def test_report_serializer_save( report = serializer.save() + expected_state = state_serializer(target).data + expected_state["_target"] = json.loads( + json.dumps(target_data, cls=DjangoJSONEncoder) + ) + expected_state["domain"] = urllib.parse.urlparse(target.fid).hostname + expected_state["is_local"] = ( + expected_state["domain"] == settings.FEDERATION_HOSTNAME + ) + assert report.target == target assert report.type == payload["type"] assert report.summary == payload["summary"] - assert report.target_state == state_serializer(target).data + assert report.target_state == expected_state + assert report.target_owner == target_owner + get_target_owner.assert_called_once_with(target) + + +@pytest.mark.parametrize( + "factory_name, target_type, id_field, state_serializer", + [("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer)], +) +def test_report_local_entity_serializer_save( + factory_name, target_type, id_field, state_serializer, factories, mocker, settings +): + target = factories[factory_name]() + target_owner = factories["federation.Actor"]() + submitter = factories["federation.Actor"]() + target_data = {"type": target_type, id_field: getattr(target, id_field)} + payload = { + "summary": "Report content", + "type": "illegal_content", + "target": target_data, + } + serializer = serializers.ReportSerializer( + data=payload, context={"submitter": submitter} + ) + get_target_owner = mocker.patch.object( + serializers, "get_target_owner", return_value=target_owner + ) + assert serializer.is_valid(raise_exception=True) is True + + report = serializer.save() + + expected_state = state_serializer(target).data + expected_state["_target"] = json.loads( + json.dumps(target_data, cls=DjangoJSONEncoder) + ) + expected_state["is_local"] = True + assert report.target == target + assert report.type == payload["type"] + assert report.summary == payload["summary"] + assert report.target_state == expected_state assert report.target_owner == target_owner get_target_owner.assert_called_once_with(target) diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py index 384e6ee8f..4943a8a1e 100644 --- a/api/tests/users/oauth/test_scopes.py +++ b/api/tests/users/oauth/test_scopes.py @@ -50,6 +50,10 @@ from funkwhale_api.users.oauth import scopes "write:instance:edits", "read:instance:libraries", "write:instance:libraries", + "read:instance:notes", + "write:instance:notes", + "read:instance:reports", + "write:instance:reports", }, ), ( @@ -91,6 +95,10 @@ from funkwhale_api.users.oauth import scopes "write:instance:users", "read:instance:invitations", "write:instance:invitations", + "read:instance:notes", + "write:instance:notes", + "read:instance:reports", + "write:instance:reports", }, ), ( @@ -126,6 +134,10 @@ from funkwhale_api.users.oauth import scopes "write:instance:accounts", "read:instance:domains", "write:instance:domains", + "read:instance:notes", + "write:instance:notes", + "read:instance:reports", + "write:instance:reports", }, ), ( diff --git a/front/package.json b/front/package.json index 2c8f041f0..f00a0681c 100644 --- a/front/package.json +++ b/front/package.json @@ -15,14 +15,15 @@ "dateformat": "^3.0.3", "diff": "^4.0.1", "django-channels": "^1.1.6", + "fomantic-ui-css": "^2.7", "howler": "^2.0.14", "js-logger": "^1.4.1", "jwt-decode": "^2.2.0", "lodash": "^4.17.10", "masonry-layout": "^4.2.2", "moment": "^2.22.2", - "fomantic-ui-css": "^2.7", "qs": "^6.7.0", + "sanitize-html": "^1.20.1", "showdown": "^1.8.6", "vue": "^2.5.17", "vue-gettext": "^2.1.0", diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 03084a634..81b498034 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -93,8 +93,12 @@ + :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"> Moderation +
{{ $store.state.ui.notifications.pendingReviewReports }}
+ + + + + + + diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue index 518e67c86..7a3eefd33 100644 --- a/front/src/components/common/ActorLink.vue +++ b/front/src/components/common/ActorLink.vue @@ -1,5 +1,9 @@