From a6cf2ce0190dfd67d4497c7a8427e19aa3161471 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Thu, 22 Aug 2019 11:30:30 +0200
Subject: [PATCH] "[EPIC] Report option on everything - reports models

---
 api/funkwhale_api/common/fields.py            |  58 ++++++
 api/funkwhale_api/common/utils.py             |   7 +-
 .../migrations/0020_auto_20190730_0846.py     |  31 +++
 api/funkwhale_api/federation/urls.py          |   1 +
 api/funkwhale_api/federation/utils.py         |  29 +++
 api/funkwhale_api/federation/views.py         |  10 +
 api/funkwhale_api/manage/filters.py           |  17 +-
 .../dynamic_preferences_registry.py           |  16 ++
 api/funkwhale_api/moderation/factories.py     |  14 ++
 .../moderation/migrations/0003_report.py      | 100 ++++++++++
 api/funkwhale_api/moderation/models.py        |  68 +++++++
 api/funkwhale_api/moderation/serializers.py   | 184 ++++++++++++++++++
 api/funkwhale_api/moderation/urls.py          |   1 +
 api/funkwhale_api/moderation/views.py         |  22 +++
 api/funkwhale_api/music/models.py             |   3 +
 api/funkwhale_api/users/oauth/permissions.py  |   5 +-
 api/funkwhale_api/users/oauth/scopes.py       |   3 +
 api/tests/common/test_fields.py               |  47 +++++
 api/tests/moderation/test_serializers.py      | 146 ++++++++++++++
 api/tests/moderation/test_views.py            |  34 ++++
 api/tests/users/oauth/test_permissions.py     |   9 +-
 api/tests/users/oauth/test_scopes.py          |   8 +
 22 files changed, 792 insertions(+), 21 deletions(-)
 create mode 100644 api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py
 create mode 100644 api/funkwhale_api/moderation/migrations/0003_report.py

diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index b8e217ba..d86283fe 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -1,7 +1,10 @@
 import django_filters
 from django import forms
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 
+from rest_framework import serializers
+
 from . import search
 
 PRIVACY_LEVEL_CHOICES = [
@@ -52,3 +55,58 @@ class SmartSearchFilter(django_filters.CharFilter):
         except (forms.ValidationError):
             return qs.none()
         return search.apply(qs, cleaned)
+
+
+class GenericRelation(serializers.JSONField):
+    def __init__(self, choices, *args, **kwargs):
+        self.choices = choices
+        self.encoder = kwargs.setdefault("encoder", DjangoJSONEncoder)
+        super().__init__(*args, **kwargs)
+
+    def to_representation(self, value):
+        if not value:
+            return
+        type = None
+        id = None
+        for key, choice in self.choices.items():
+            if isinstance(value, choice["queryset"].model):
+                type = key
+                id = getattr(value, choice.get("id_attr", "id"))
+                break
+
+        if type:
+            return {"type": type, "id": id}
+
+    def to_internal_value(self, v):
+        v = super().to_internal_value(v)
+
+        if not v or not isinstance(v, dict):
+            raise serializers.ValidationError("Invalid data")
+
+        try:
+            type = v["type"]
+            field = serializers.ChoiceField(choices=list(self.choices.keys()))
+            type = field.to_internal_value(type)
+        except (TypeError, KeyError, serializers.ValidationError):
+            raise serializers.ValidationError("Invalid type")
+
+        conf = self.choices[type]
+        id_attr = conf.get("id_attr", "id")
+        id_field = conf.get("id_field", serializers.IntegerField(min_value=1))
+        queryset = conf["queryset"]
+        try:
+            id_value = v[id_attr]
+            id_value = id_field.to_internal_value(id_value)
+        except (TypeError, KeyError, serializers.ValidationError):
+            raise serializers.ValidationError("Invalid {}".format(id_attr))
+
+        query_getter = conf.get(
+            "get_query", lambda attr, value: models.Q(**{attr: value})
+        )
+        query = query_getter(id_attr, id_value)
+        try:
+            obj = queryset.get(query)
+        except queryset.model.DoesNotExist:
+            raise serializers.ValidationError("Object not found")
+
+        return obj
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 7763e9b7..55e4fdfb 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -154,7 +154,7 @@ def order_for_search(qs, field):
 
 def recursive_getattr(obj, key, permissive=False):
     """
-    Given a dictionary such as {'user': {'name': 'Bob'}} and
+    Given a dictionary such as {'user': {'name': 'Bob'}} or and object and
     a dotted string such as user.name, returns 'Bob'.
 
     If the value is not present, returns None
@@ -162,7 +162,10 @@ def recursive_getattr(obj, key, permissive=False):
     v = obj
     for k in key.split("."):
         try:
-            v = v.get(k)
+            if hasattr(v, "get"):
+                v = v.get(k)
+            else:
+                v = getattr(v, k)
         except (TypeError, AttributeError):
             if not permissive:
                 raise
diff --git a/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py b/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py
new file mode 100644
index 00000000..efed1d30
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0020_auto_20190730_0846.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.3 on 2019-07-30 08:46
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations
+import funkwhale_api.federation.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('federation', '0019_auto_20190611_0851'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='activity',
+            name='payload',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
+        ),
+        migrations.AlterField(
+            model_name='fetch',
+            name='detail',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
+        ),
+        migrations.AlterField(
+            model_name='librarytrack',
+            name='metadata',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.federation.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
index f7d5006d..a193087d 100644
--- a/api/funkwhale_api/federation/urls.py
+++ b/api/funkwhale_api/federation/urls.py
@@ -9,6 +9,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
 router.register(r"federation/shared", views.SharedViewSet, "shared")
 router.register(r"federation/actors", views.ActorViewSet, "actors")
 router.register(r"federation/edits", views.EditViewSet, "edits")
+router.register(r"federation/reports", views.ReportViewSet, "reports")
 router.register(r".well-known", views.WellKnownViewSet, "well-known")
 
 music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index c66a9726..c2eacfe9 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -128,3 +128,32 @@ def is_local(url):
     return url.startswith("http://{}/".format(d)) or url.startswith(
         "https://{}/".format(d)
     )
+
+
+def get_actor_data_from_username(username):
+
+    parts = username.split("@")
+
+    return {
+        "username": parts[0],
+        "domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME,
+    }
+
+
+def get_actor_from_username_data_query(field, data):
+    if not data:
+        return Q(**{field: None})
+    if field:
+        return Q(
+            **{
+                "{}__preferred_username__iexact".format(field): data["username"],
+                "{}__domain__name__iexact".format(field): data["domain"],
+            }
+        )
+    else:
+        return Q(
+            **{
+                "preferred_username__iexact": data["username"],
+                "domain__name__iexact": data["domain"],
+            }
+        )
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 85961e32..85594e02 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -6,6 +6,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets
 from rest_framework.decorators import action
 
 from funkwhale_api.common import preferences
+from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import utils as music_utils
 
@@ -86,6 +87,15 @@ class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericVi
     # serializer_class = serializers.ActorSerializer
 
 
+class ReportViewSet(
+    FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
+):
+    lookup_field = "uuid"
+    authentication_classes = [authentication.SignatureAuthentication]
+    renderer_classes = renderers.get_ap_renderers()
+    queryset = moderation_models.Report.objects.none()
+
+
 class WellKnownViewSet(viewsets.GenericViewSet):
     authentication_classes = []
     permission_classes = []
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index af9ded74..6a6e7b99 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -1,6 +1,5 @@
 from django import forms
 from django.db.models import Q
-from django.conf import settings
 
 import django_filters
 from django_filters import rest_framework as filters
@@ -22,24 +21,12 @@ class ActorField(forms.CharField):
         if not value:
             return value
 
-        parts = value.split("@")
-
-        return {
-            "username": parts[0],
-            "domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME,
-        }
+        return federation_utils.get_actor_data_from_username(value)
 
 
 def get_actor_filter(actor_field):
     def handler(v):
-        if not v:
-            return Q(**{actor_field: None})
-        return Q(
-            **{
-                "{}__preferred_username__iexact".format(actor_field): v["username"],
-                "{}__domain__name__iexact".format(actor_field): v["domain"],
-            }
-        )
+        federation_utils.get_actor_from_username_data_query(actor_field, v)
 
     return {"field": ActorField(), "handler": handler}
 
diff --git a/api/funkwhale_api/moderation/dynamic_preferences_registry.py b/api/funkwhale_api/moderation/dynamic_preferences_registry.py
index 04a732f4..29390434 100644
--- a/api/funkwhale_api/moderation/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/moderation/dynamic_preferences_registry.py
@@ -1,6 +1,10 @@
 from dynamic_preferences import types
 from dynamic_preferences.registries import global_preferences_registry
 
+from funkwhale_api.common import preferences as common_preferences
+
+from . import models
+
 moderation = types.Section("moderation")
 
 
@@ -24,3 +28,15 @@ class AllowListPublic(types.BooleanPreference):
         "make your moderation policy public."
     )
     default = False
+
+
+@global_preferences_registry.register
+class UnauthenticatedReportTypes(common_preferences.StringListPreference):
+    show_in_api = True
+    section = moderation
+    name = "unauthenticated_report_types"
+    default = ["takedown_request", "illegal_content"]
+    verbose_name = "Accountless report categories"
+    help_text = "A list of categories for which external users (without an account) can submit a report"
+    choices = models.REPORT_TYPES
+    field_kwargs = {"choices": choices, "required": False}
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
index 8829caa2..4bf7ce58 100644
--- a/api/funkwhale_api/moderation/factories.py
+++ b/api/funkwhale_api/moderation/factories.py
@@ -37,3 +37,17 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
         for_artist = factory.Trait(
             target_artist=factory.SubFactory(music_factories.ArtistFactory)
         )
+
+
+@registry.register
+class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
+    submitter = factory.SubFactory(federation_factories.ActorFactory)
+    target = None
+    summary = factory.Faker("paragraph")
+    type = "other"
+
+    class Meta:
+        model = "moderation.Report"
+
+    class Params:
+        anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email"))
diff --git a/api/funkwhale_api/moderation/migrations/0003_report.py b/api/funkwhale_api/moderation/migrations/0003_report.py
new file mode 100644
index 00000000..c5609245
--- /dev/null
+++ b/api/funkwhale_api/moderation/migrations/0003_report.py
@@ -0,0 +1,100 @@
+# Generated by Django 2.2.3 on 2019-08-01 08:34
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("federation", "0020_auto_20190730_0846"),
+        ("moderation", "0002_auto_20190213_0927"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Report",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("fid", models.URLField(db_index=True, max_length=500, unique=True)),
+                ("url", models.URLField(blank=True, max_length=500, null=True)),
+                ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("summary", models.TextField(max_length=50000, null=True)),
+                ("handled_date", models.DateTimeField(null=True)),
+                ("is_handled", models.BooleanField(default=False)),
+                (
+                    "type",
+                    models.CharField(
+                        choices=[
+                            ("takedown_request", "Takedown request"),
+                            ("invalid_metadata", "Invalid metadata"),
+                            ("illegal_content", "Illegal content"),
+                            ("offensive_content", "Offensive content"),
+                            ("other", "Other"),
+                        ],
+                        max_length=40,
+                    ),
+                ),
+                ("submitter_email", models.EmailField(max_length=254, null=True)),
+                ("target_id", models.IntegerField(null=True)),
+                (
+                    "target_state",
+                    django.contrib.postgres.fields.jsonb.JSONField(null=True),
+                ),
+                (
+                    "submitter",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="reports",
+                        to="federation.Actor",
+                    ),
+                ),
+                (
+                    "assigned_to",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="assigned_reports",
+                        to="federation.Actor",
+                    ),
+                ),
+                (
+                    "target_content_type",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="contenttypes.ContentType",
+                    ),
+                ),
+                (
+                    "target_owner",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="federation.Actor",
+                    ),
+                ),
+            ],
+            options={"abstract": False},
+        )
+    ]
diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py
index 7ade5d05..ccc891e7 100644
--- a/api/funkwhale_api/moderation/models.py
+++ b/api/funkwhale_api/moderation/models.py
@@ -1,9 +1,17 @@
 import urllib.parse
 import uuid
 
+
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.postgres.fields import JSONField
 from django.db import models
+from django.urls import reverse
 from django.utils import timezone
 
+from funkwhale_api.federation import models as federation_models
+from funkwhale_api.federation import utils as federation_utils
+
 
 class InstancePolicyQuerySet(models.QuerySet):
     def active(self):
@@ -92,3 +100,63 @@ class UserFilter(models.Model):
     def target(self):
         if self.target_artist:
             return {"type": "artist", "obj": self.target_artist}
+
+
+REPORT_TYPES = [
+    ("takedown_request", "Takedown request"),
+    ("invalid_metadata", "Invalid metadata"),
+    ("illegal_content", "Illegal content"),
+    ("offensive_content", "Offensive content"),
+    ("other", "Other"),
+]
+
+
+class Report(federation_models.FederationMixin):
+    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+    creation_date = models.DateTimeField(default=timezone.now)
+    summary = models.TextField(null=True, max_length=50000)
+    handled_date = models.DateTimeField(null=True)
+    is_handled = models.BooleanField(default=False)
+    type = models.CharField(max_length=40, choices=REPORT_TYPES)
+    submitter_email = models.EmailField(null=True)
+    submitter = models.ForeignKey(
+        "federation.Actor",
+        related_name="reports",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+
+    assigned_to = models.ForeignKey(
+        "federation.Actor",
+        related_name="assigned_reports",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+
+    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")
+    target_owner = models.ForeignKey(
+        "federation.Actor", on_delete=models.SET_NULL, null=True, blank=True
+    )
+    # frozen state of the target being reported, to ensure we still have info in the event of a
+    # delete
+    target_state = JSONField(null=True)
+
+    def get_federation_id(self):
+        if self.fid:
+            return self.fid
+
+        return federation_utils.full_url(
+            reverse("federation:reports-detail", kwargs={"uuid": self.uuid})
+        )
+
+    def save(self, **kwargs):
+        if not self.pk and not self.fid:
+            self.fid = self.get_federation_id()
+
+        return super().save(**kwargs)
diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py
index 20c34242..c52a9e91 100644
--- a/api/funkwhale_api/moderation/serializers.py
+++ b/api/funkwhale_api/moderation/serializers.py
@@ -1,6 +1,14 @@
+import persisting_theory
+
 from rest_framework import serializers
 
+from funkwhale_api.common import fields as common_fields
+from funkwhale_api.common import preferences
+from funkwhale_api.federation import models as federation_models
+from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.music import models as music_models
+from funkwhale_api.playlists import models as playlists_models
+
 from . import models
 
 
@@ -43,3 +51,179 @@ class UserFilterSerializer(serializers.ModelSerializer):
             data["target_artist"] = target["obj"]
 
         return data
+
+
+state_serializers = persisting_theory.Registry()
+
+
+TAGS_FIELD = serializers.ListField(source="get_tags")
+
+
+@state_serializers.register(name="music.Artist")
+class ArtistStateSerializer(serializers.ModelSerializer):
+    tags = TAGS_FIELD
+
+    class Meta:
+        model = music_models.Artist
+        fields = ["id", "name", "mbid", "fid", "creation_date", "uuid", "tags"]
+
+
+@state_serializers.register(name="music.Album")
+class AlbumStateSerializer(serializers.ModelSerializer):
+    tags = TAGS_FIELD
+    artist = ArtistStateSerializer()
+
+    class Meta:
+        model = music_models.Album
+        fields = [
+            "id",
+            "title",
+            "mbid",
+            "fid",
+            "creation_date",
+            "uuid",
+            "artist",
+            "release_date",
+            "tags",
+        ]
+
+
+@state_serializers.register(name="music.Track")
+class TrackStateSerializer(serializers.ModelSerializer):
+    tags = TAGS_FIELD
+    artist = ArtistStateSerializer()
+    album = AlbumStateSerializer()
+
+    class Meta:
+        model = music_models.Track
+        fields = [
+            "id",
+            "title",
+            "mbid",
+            "fid",
+            "creation_date",
+            "uuid",
+            "artist",
+            "album",
+            "disc_number",
+            "position",
+            "license",
+            "copyright",
+            "tags",
+        ]
+
+
+@state_serializers.register(name="music.Library")
+class LibraryStateSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = music_models.Library
+        fields = ["id", "fid", "name", "description", "creation_date", "privacy_level"]
+
+
+@state_serializers.register(name="playlists.Playlist")
+class PlaylistStateSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = playlists_models.Playlist
+        fields = ["id", "name", "creation_date", "privacy_level"]
+
+
+@state_serializers.register(name="federation.Actor")
+class ActorStateSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = federation_models.Actor
+        fields = [
+            "fid",
+            "name",
+            "preferred_username",
+            "summary",
+            "domain",
+            "type",
+            "creation_date",
+        ]
+
+
+def get_actor_query(attr, value):
+    data = federation_utils.get_actor_data_from_username(value)
+    return federation_utils.get_actor_from_username_data_query(None, data)
+
+
+def get_target_owner(target):
+    mapping = {
+        music_models.Artist: lambda t: t.attributed_to,
+        music_models.Album: lambda t: t.attributed_to,
+        music_models.Track: lambda t: t.attributed_to,
+        music_models.Library: lambda t: t.actor,
+        playlists_models.Playlist: lambda t: t.user.actor,
+        federation_models.Actor: lambda t: t,
+    }
+
+    return mapping[target.__class__](target)
+
+
+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,
+            },
+        }
+    )
+
+    class Meta:
+        model = models.Report
+        fields = [
+            "uuid",
+            "summary",
+            "creation_date",
+            "handled_date",
+            "is_handled",
+            "submitter_email",
+            "target",
+            "type",
+        ]
+        read_only_fields = ["uuid", "is_handled", "creation_date", "handled_date"]
+
+    def validate(self, validated_data):
+        validated_data = super().validate(validated_data)
+        submitter = self.context.get("submitter")
+        if submitter:
+            # we have an authenticated actor so no need to check further
+            return validated_data
+
+        unauthenticated_report_types = preferences.get(
+            "moderation__unauthenticated_report_types"
+        )
+        if validated_data["type"] not in unauthenticated_report_types:
+            raise serializers.ValidationError(
+                "You need an account to submit this report"
+            )
+
+        if not validated_data.get("submitter_email"):
+            raise serializers.ValidationError(
+                "You need to provide an email address to submit this report"
+            )
+
+        return validated_data
+
+    def create(self, validated_data):
+        target_state_serializer = state_serializers[
+            validated_data["target"]._meta.label
+        ]
+
+        validated_data["target_state"] = target_state_serializer(
+            validated_data["target"]
+        ).data
+        validated_data["target_owner"] = get_target_owner(validated_data["target"])
+        return super().create(validated_data)
diff --git a/api/funkwhale_api/moderation/urls.py b/api/funkwhale_api/moderation/urls.py
index cd3e7bc2..597aacad 100644
--- a/api/funkwhale_api/moderation/urls.py
+++ b/api/funkwhale_api/moderation/urls.py
@@ -4,5 +4,6 @@ from . import views
 
 router = routers.OptionalSlashRouter()
 router.register(r"content-filters", views.UserFilterViewSet, "content-filters")
+router.register(r"reports", views.ReportsViewSet, "reports")
 
 urlpatterns = router.urls
diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py
index 4d4e3e03..e3725107 100644
--- a/api/funkwhale_api/moderation/views.py
+++ b/api/funkwhale_api/moderation/views.py
@@ -39,3 +39,25 @@ class UserFilterViewSet(
 
     def perform_create(self, serializer):
         serializer.save(user=self.request.user)
+
+
+class ReportsViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
+    lookup_field = "uuid"
+    queryset = models.Report.objects.all().order_by("-creation_date")
+    serializer_class = serializers.ReportSerializer
+    required_scope = "reports"
+    ordering_fields = ("creation_date",)
+    anonymous_policy = "setting"
+    anonymous_scopes = {"write:reports"}
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        if self.request.user.is_authenticated:
+            context["submitter"] = self.request.user.actor
+        return context
+
+    def perform_create(self, serializer):
+        submitter = None
+        if self.request.user.is_authenticated:
+            submitter = self.request.user.actor
+        serializer.save(submitter=submitter)
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 3e6d892a..fc4118a9 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -127,6 +127,9 @@ class APIModelMixin(models.Model):
         parsed = urllib.parse.urlparse(self.fid)
         return parsed.hostname
 
+    def get_tags(self):
+        return list(sorted(self.tagged_items.values_list("tag__name", flat=True)))
+
 
 class License(models.Model):
     code = models.CharField(primary_key=True, max_length=100)
diff --git a/api/funkwhale_api/users/oauth/permissions.py b/api/funkwhale_api/users/oauth/permissions.py
index ebd44a93..54b3c262 100644
--- a/api/funkwhale_api/users/oauth/permissions.py
+++ b/api/funkwhale_api/users/oauth/permissions.py
@@ -96,8 +96,9 @@ class ScopePermission(permissions.BasePermission):
             ):
                 return False
 
-            # we use default anonymous scopes
-            user_scopes = scopes.ANONYMOUS_SCOPES
+            user_scopes = (
+                getattr(view, "anonymous_scopes", set()) | scopes.ANONYMOUS_SCOPES
+            )
             return should_allow(
                 required_scope=required_scope, request_scopes=user_scopes
             )
diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py
index 61b07098..8cf91192 100644
--- a/api/funkwhale_api/users/oauth/scopes.py
+++ b/api/funkwhale_api/users/oauth/scopes.py
@@ -22,6 +22,7 @@ BASE_SCOPES = [
     Scope("playlists", "Access playlists"),
     Scope("notifications", "Access personal notifications"),
     Scope("security", "Access security settings"),
+    Scope("reports", "Access reports"),
     # Privileged scopes that require specific user permissions
     Scope("instance:settings", "Access instance settings"),
     Scope("instance:users", "Access local user accounts"),
@@ -72,6 +73,8 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | {
     "write:edits",
     "read:filters",
     "write:filters",
+    "read:reports",
+    "write:reports",
     "write:listenings",
 }
 
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
index 72aa8b4c..21e85b70 100644
--- a/api/tests/common/test_fields.py
+++ b/api/tests/common/test_fields.py
@@ -20,3 +20,50 @@ from funkwhale_api.users.factories import UserFactory
 def test_privacy_level_query(user, expected):
     query = fields.privacy_level_query(user)
     assert query == expected
+
+
+def test_generic_relation_field(factories):
+    obj = factories["users.User"]()
+    f = fields.GenericRelation(
+        {
+            "user": {
+                "queryset": obj.__class__.objects.all(),
+                "id_attr": "username",
+                "id_field": fields.serializers.CharField(),
+            }
+        }
+    )
+
+    data = {"type": "user", "username": obj.username}
+
+    assert f.to_internal_value(data) == obj
+
+
+@pytest.mark.parametrize(
+    "payload, expected_error",
+    [
+        ({}, r".*Invalid data.*"),
+        (1, r".*Invalid data.*"),
+        (False, r".*Invalid data.*"),
+        ("test", r".*Invalid data.*"),
+        ({"missing": "type"}, r".*Invalid type.*"),
+        ({"type": "noop"}, r".*Invalid type.*"),
+        ({"type": "user"}, r".*Invalid username.*"),
+        ({"type": "user", "username": {}}, r".*Invalid username.*"),
+        ({"type": "user", "username": "not_found"}, r".*Object not found.*"),
+    ],
+)
+def test_generic_relation_field_validation_error(payload, expected_error, factories):
+    obj = factories["users.User"]()
+    f = fields.GenericRelation(
+        {
+            "user": {
+                "queryset": obj.__class__.objects.all(),
+                "id_attr": "username",
+                "id_field": fields.serializers.CharField(),
+            }
+        }
+    )
+
+    with pytest.raises(fields.serializers.ValidationError, match=expected_error):
+        f.to_internal_value(payload)
diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py
index a3821414..37c95c78 100644
--- a/api/tests/moderation/test_serializers.py
+++ b/api/tests/moderation/test_serializers.py
@@ -1,3 +1,7 @@
+import pytest
+
+from funkwhale_api.common import utils as common_utils
+from funkwhale_api.federation import models as federation_models
 from funkwhale_api.moderation import serializers
 
 
@@ -28,3 +32,145 @@ def test_user_filter_serializer_save(factories):
     content_filter = serializer.save(user=user)
 
     assert content_filter.target_artist == artist
+
+
+@pytest.mark.parametrize(
+    "factory_name, target_type, id_field, state_serializer",
+    [
+        ("music.Artist", "artist", "id", serializers.ArtistStateSerializer),
+        ("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",
+            "full_username",
+            serializers.ActorStateSerializer,
+        ),
+    ],
+)
+def test_report_serializer_save(
+    factory_name, target_type, id_field, state_serializer, factories, mocker
+):
+    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()
+
+    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_owner == target_owner
+    get_target_owner.assert_called_once_with(target)
+
+
+def test_report_serializer_save_anonymous(factories, mocker):
+    target = factories["music.Artist"]()
+    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()
+
+    assert report.target == target
+    assert report.type == payload["type"]
+    assert report.summary == payload["summary"]
+    assert report.submitter_email == payload["submitter_email"]
+
+
+@pytest.mark.parametrize(
+    "factory_name, factory_kwargs, owner_field",
+    [
+        ("music.Artist", {"attributed": True}, "attributed_to"),
+        ("music.Album", {"attributed": True}, "attributed_to"),
+        ("music.Track", {"attributed": True}, "attributed_to"),
+        ("music.Library", {}, "actor"),
+        ("playlists.Playlist", {"user__with_actor": True}, "user.actor"),
+        ("federation.Actor", {}, "self"),
+    ],
+)
+def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories):
+    target = factories[factory_name](**factory_kwargs)
+    if owner_field == "self":
+        expected_owner = target
+    else:
+        expected_owner = common_utils.recursive_getattr(target, owner_field)
+
+    assert isinstance(expected_owner, federation_models.Actor)
+    assert serializers.get_target_owner(target) == expected_owner
+
+
+def test_report_serializer_repr(factories, to_api_date):
+    target = factories["music.Artist"]()
+    report = factories["moderation.Report"](target=target)
+    expected = {
+        "uuid": str(report.uuid),
+        "summary": report.summary,
+        "type": report.type,
+        "target": {"type": "artist", "id": target.pk},
+        "creation_date": to_api_date(report.creation_date),
+        "handled_date": None,
+        "is_handled": False,
+        "submitter_email": None,
+    }
+    serializer = serializers.ReportSerializer(report)
+    assert serializer.data == expected
+
+
+@pytest.mark.parametrize(
+    "preference, context, payload, is_valid",
+    [
+        # anonymous reports not enabled for the category
+        (
+            ["illegal_content"],
+            {},
+            {"type": "other", "submitter_email": "hello@example.test"},
+            False,
+        ),
+        # anonymous reports enabled for the category, but invalid email
+        (["other"], {}, {"type": "other", "submitter_email": "hello@"}, False),
+        # anonymous reports enabled for the category, no email
+        (["other"], {}, {"type": "other"}, False),
+        # anonymous reports enabled for the category, actor object is empty
+        (["other"], {"submitter": None}, {"type": "other"}, False),
+        # valid examples
+        (
+            ["other"],
+            {},
+            {"type": "other", "submitter_email": "hello@example.test"},
+            True,
+        ),
+    ],
+)
+def test_report_serializer_save_unauthenticated_validation(
+    preference, context, payload, is_valid, factories, preferences
+):
+    preferences["moderation__unauthenticated_report_types"] = preference
+    target = factories["music.Artist"]()
+    target_data = {"type": "artist", "id": target.id}
+    payload["summary"] = "Test"
+    payload["target"] = target_data
+    serializer = serializers.ReportSerializer(data=payload, context=context)
+    assert serializer.is_valid() is is_valid
diff --git a/api/tests/moderation/test_views.py b/api/tests/moderation/test_views.py
index 3d53f456..dba22817 100644
--- a/api/tests/moderation/test_views.py
+++ b/api/tests/moderation/test_views.py
@@ -1,5 +1,7 @@
 from django.urls import reverse
 
+from funkwhale_api.moderation import models
+
 
 def test_restrict_to_own_filters(factories, logged_in_api_client):
     cf = factories["moderation.UserFilter"](
@@ -22,3 +24,35 @@ def test_create_filter(factories, logged_in_api_client):
     cf = logged_in_api_client.user.content_filters.latest("id")
     assert cf.target_artist == artist
     assert response.status_code == 201
+
+
+def test_create_report_logged_in(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    target = factories["music.Artist"]()
+    url = reverse("api:v1:moderation:reports-list")
+    data = {
+        "target": {"type": "artist", "id": target.pk},
+        "summary": "Test report",
+        "type": "other",
+    }
+    response = logged_in_api_client.post(url, data, format="json")
+
+    assert response.status_code == 201
+    report = actor.reports.latest("id")
+    assert report.target == target
+
+
+def test_create_report_anonymous(factories, api_client, no_api_auth):
+    target = factories["music.Artist"]()
+    url = reverse("api:v1:moderation:reports-list")
+    data = {
+        "target": {"type": "artist", "id": target.pk},
+        "summary": "Test report",
+        "type": "illegal_content",
+        "submitter_email": "test@example.test",
+    }
+    response = api_client.post(url, data, format="json")
+
+    assert response.status_code == 201
+    report = models.Report.objects.latest("id")
+    assert report.submitter_email == data["submitter_email"]
diff --git a/api/tests/users/oauth/test_permissions.py b/api/tests/users/oauth/test_permissions.py
index 65974fbf..a5cd1203 100644
--- a/api/tests/users/oauth/test_permissions.py
+++ b/api/tests/users/oauth/test_permissions.py
@@ -59,7 +59,9 @@ def test_scope_permission_anonymous_policy(
     policy, preference, expected, preferences, mocker, anonymous_user
 ):
     preferences["common__api_authentication_required"] = preference
-    view = mocker.Mock(required_scope="libraries", anonymous_policy=policy)
+    view = mocker.Mock(
+        required_scope="libraries", anonymous_policy=policy, anonymous_scopes=set()
+    )
     request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
 
     p = permissions.ScopePermission()
@@ -72,6 +74,7 @@ def test_scope_permission_dict_no_required(mocker, anonymous_user):
         required_scope={"read": None, "write": "write:profile"},
         anonymous_policy=True,
         action="read",
+        anonymous_scopes=set(),
     )
     request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
 
@@ -164,7 +167,9 @@ def test_scope_permission_token_anonymous_user_auth_not_required(
     preferences["common__api_authentication_required"] = False
     should_allow = mocker.patch.object(permissions, "should_allow")
     request = mocker.Mock(method="POST", user=anonymous_user, actor=None)
-    view = mocker.Mock(required_scope="profile", anonymous_policy="setting")
+    view = mocker.Mock(
+        required_scope="profile", anonymous_policy="setting", anonymous_scopes=set()
+    )
 
     p = permissions.ScopePermission()
 
diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py
index 3d12cb66..384e6ee8 100644
--- a/api/tests/users/oauth/test_scopes.py
+++ b/api/tests/users/oauth/test_scopes.py
@@ -28,6 +28,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:edits",
                 "read:filters",
                 "write:filters",
+                "read:reports",
+                "write:reports",
                 "read:listenings",
                 "write:listenings",
                 "read:security",
@@ -71,6 +73,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:edits",
                 "read:filters",
                 "write:filters",
+                "read:reports",
+                "write:reports",
                 "read:listenings",
                 "write:listenings",
                 "read:security",
@@ -110,6 +114,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:edits",
                 "read:filters",
                 "write:filters",
+                "read:reports",
+                "write:reports",
                 "read:listenings",
                 "write:listenings",
                 "read:security",
@@ -143,6 +149,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:edits",
                 "read:filters",
                 "write:filters",
+                "read:reports",
+                "write:reports",
                 "read:listenings",
                 "write:listenings",
                 "read:security",
-- 
GitLab