"[EPIC] Report option on everything - reports models

This commit is contained in:
Eliot Berriot 2019-08-22 11:30:30 +02:00
parent 079671ef7a
commit a6cf2ce019
22 changed files with 792 additions and 21 deletions

View File

@ -1,7 +1,10 @@
import django_filters import django_filters
from django import forms from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
from rest_framework import serializers
from . import search from . import search
PRIVACY_LEVEL_CHOICES = [ PRIVACY_LEVEL_CHOICES = [
@ -52,3 +55,58 @@ class SmartSearchFilter(django_filters.CharFilter):
except (forms.ValidationError): except (forms.ValidationError):
return qs.none() return qs.none()
return search.apply(qs, cleaned) 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

View File

@ -154,7 +154,7 @@ def order_for_search(qs, field):
def recursive_getattr(obj, key, permissive=False): 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'. a dotted string such as user.name, returns 'Bob'.
If the value is not present, returns None If the value is not present, returns None
@ -162,7 +162,10 @@ def recursive_getattr(obj, key, permissive=False):
v = obj v = obj
for k in key.split("."): for k in key.split("."):
try: try:
v = v.get(k) if hasattr(v, "get"):
v = v.get(k)
else:
v = getattr(v, k)
except (TypeError, AttributeError): except (TypeError, AttributeError):
if not permissive: if not permissive:
raise raise

View File

@ -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),
),
]

View File

@ -9,6 +9,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
router.register(r"federation/shared", views.SharedViewSet, "shared") router.register(r"federation/shared", views.SharedViewSet, "shared")
router.register(r"federation/actors", views.ActorViewSet, "actors") router.register(r"federation/actors", views.ActorViewSet, "actors")
router.register(r"federation/edits", views.EditViewSet, "edits") 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") router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")

View File

@ -128,3 +128,32 @@ def is_local(url):
return url.startswith("http://{}/".format(d)) or url.startswith( return url.startswith("http://{}/".format(d)) or url.startswith(
"https://{}/".format(d) "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"],
}
)

View File

@ -6,6 +6,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from funkwhale_api.common import preferences 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 models as music_models
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
@ -86,6 +87,15 @@ class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericVi
# serializer_class = serializers.ActorSerializer # 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): class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = [] authentication_classes = []
permission_classes = [] permission_classes = []

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.db.models import Q from django.db.models import Q
from django.conf import settings
import django_filters import django_filters
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
@ -22,24 +21,12 @@ class ActorField(forms.CharField):
if not value: if not value:
return value return value
parts = value.split("@") return federation_utils.get_actor_data_from_username(value)
return {
"username": parts[0],
"domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME,
}
def get_actor_filter(actor_field): def get_actor_filter(actor_field):
def handler(v): def handler(v):
if not v: federation_utils.get_actor_from_username_data_query(actor_field, v)
return Q(**{actor_field: None})
return Q(
**{
"{}__preferred_username__iexact".format(actor_field): v["username"],
"{}__domain__name__iexact".format(actor_field): v["domain"],
}
)
return {"field": ActorField(), "handler": handler} return {"field": ActorField(), "handler": handler}

View File

@ -1,6 +1,10 @@
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences as common_preferences
from . import models
moderation = types.Section("moderation") moderation = types.Section("moderation")
@ -24,3 +28,15 @@ class AllowListPublic(types.BooleanPreference):
"make your moderation policy public." "make your moderation policy public."
) )
default = False 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}

View File

@ -37,3 +37,17 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
for_artist = factory.Trait( for_artist = factory.Trait(
target_artist=factory.SubFactory(music_factories.ArtistFactory) 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"))

View File

@ -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},
)
]

View File

@ -1,9 +1,17 @@
import urllib.parse import urllib.parse
import uuid 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.db import models
from django.urls import reverse
from django.utils import timezone 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): class InstancePolicyQuerySet(models.QuerySet):
def active(self): def active(self):
@ -92,3 +100,63 @@ class UserFilter(models.Model):
def target(self): def target(self):
if self.target_artist: if self.target_artist:
return {"type": "artist", "obj": 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)

View File

@ -1,6 +1,14 @@
import persisting_theory
from rest_framework import serializers 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.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from . import models from . import models
@ -43,3 +51,179 @@ class UserFilterSerializer(serializers.ModelSerializer):
data["target_artist"] = target["obj"] data["target_artist"] = target["obj"]
return data 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)

View File

@ -4,5 +4,6 @@ from . import views
router = routers.OptionalSlashRouter() router = routers.OptionalSlashRouter()
router.register(r"content-filters", views.UserFilterViewSet, "content-filters") router.register(r"content-filters", views.UserFilterViewSet, "content-filters")
router.register(r"reports", views.ReportsViewSet, "reports")
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -39,3 +39,25 @@ class UserFilterViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(user=self.request.user) 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)

View File

@ -127,6 +127,9 @@ class APIModelMixin(models.Model):
parsed = urllib.parse.urlparse(self.fid) parsed = urllib.parse.urlparse(self.fid)
return parsed.hostname return parsed.hostname
def get_tags(self):
return list(sorted(self.tagged_items.values_list("tag__name", flat=True)))
class License(models.Model): class License(models.Model):
code = models.CharField(primary_key=True, max_length=100) code = models.CharField(primary_key=True, max_length=100)

View File

@ -96,8 +96,9 @@ class ScopePermission(permissions.BasePermission):
): ):
return False return False
# we use default anonymous scopes user_scopes = (
user_scopes = scopes.ANONYMOUS_SCOPES getattr(view, "anonymous_scopes", set()) | scopes.ANONYMOUS_SCOPES
)
return should_allow( return should_allow(
required_scope=required_scope, request_scopes=user_scopes required_scope=required_scope, request_scopes=user_scopes
) )

View File

@ -22,6 +22,7 @@ BASE_SCOPES = [
Scope("playlists", "Access playlists"), Scope("playlists", "Access playlists"),
Scope("notifications", "Access personal notifications"), Scope("notifications", "Access personal notifications"),
Scope("security", "Access security settings"), Scope("security", "Access security settings"),
Scope("reports", "Access reports"),
# Privileged scopes that require specific user permissions # Privileged scopes that require specific user permissions
Scope("instance:settings", "Access instance settings"), Scope("instance:settings", "Access instance settings"),
Scope("instance:users", "Access local user accounts"), Scope("instance:users", "Access local user accounts"),
@ -72,6 +73,8 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | {
"write:edits", "write:edits",
"read:filters", "read:filters",
"write:filters", "write:filters",
"read:reports",
"write:reports",
"write:listenings", "write:listenings",
} }

View File

@ -20,3 +20,50 @@ from funkwhale_api.users.factories import UserFactory
def test_privacy_level_query(user, expected): def test_privacy_level_query(user, expected):
query = fields.privacy_level_query(user) query = fields.privacy_level_query(user)
assert query == expected 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)

View File

@ -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 from funkwhale_api.moderation import serializers
@ -28,3 +32,145 @@ def test_user_filter_serializer_save(factories):
content_filter = serializer.save(user=user) content_filter = serializer.save(user=user)
assert content_filter.target_artist == artist 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

View File

@ -1,5 +1,7 @@
from django.urls import reverse from django.urls import reverse
from funkwhale_api.moderation import models
def test_restrict_to_own_filters(factories, logged_in_api_client): def test_restrict_to_own_filters(factories, logged_in_api_client):
cf = factories["moderation.UserFilter"]( 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") cf = logged_in_api_client.user.content_filters.latest("id")
assert cf.target_artist == artist assert cf.target_artist == artist
assert response.status_code == 201 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"]

View File

@ -59,7 +59,9 @@ def test_scope_permission_anonymous_policy(
policy, preference, expected, preferences, mocker, anonymous_user policy, preference, expected, preferences, mocker, anonymous_user
): ):
preferences["common__api_authentication_required"] = preference 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) request = mocker.Mock(method="GET", user=anonymous_user, actor=None)
p = permissions.ScopePermission() p = permissions.ScopePermission()
@ -72,6 +74,7 @@ def test_scope_permission_dict_no_required(mocker, anonymous_user):
required_scope={"read": None, "write": "write:profile"}, required_scope={"read": None, "write": "write:profile"},
anonymous_policy=True, anonymous_policy=True,
action="read", action="read",
anonymous_scopes=set(),
) )
request = mocker.Mock(method="GET", user=anonymous_user, actor=None) 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 preferences["common__api_authentication_required"] = False
should_allow = mocker.patch.object(permissions, "should_allow") should_allow = mocker.patch.object(permissions, "should_allow")
request = mocker.Mock(method="POST", user=anonymous_user, actor=None) 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() p = permissions.ScopePermission()

View File

@ -28,6 +28,8 @@ from funkwhale_api.users.oauth import scopes
"write:edits", "write:edits",
"read:filters", "read:filters",
"write:filters", "write:filters",
"read:reports",
"write:reports",
"read:listenings", "read:listenings",
"write:listenings", "write:listenings",
"read:security", "read:security",
@ -71,6 +73,8 @@ from funkwhale_api.users.oauth import scopes
"write:edits", "write:edits",
"read:filters", "read:filters",
"write:filters", "write:filters",
"read:reports",
"write:reports",
"read:listenings", "read:listenings",
"write:listenings", "write:listenings",
"read:security", "read:security",
@ -110,6 +114,8 @@ from funkwhale_api.users.oauth import scopes
"write:edits", "write:edits",
"read:filters", "read:filters",
"write:filters", "write:filters",
"read:reports",
"write:reports",
"read:listenings", "read:listenings",
"write:listenings", "write:listenings",
"read:security", "read:security",
@ -143,6 +149,8 @@ from funkwhale_api.users.oauth import scopes
"write:edits", "write:edits",
"read:filters", "read:filters",
"write:filters", "write:filters",
"read:reports",
"write:reports",
"read:listenings", "read:listenings",
"write:listenings", "write:listenings",
"read:security", "read:security",