Commit bc39b181 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '890-mods-workflow' into 'develop'

moderator interface for reports (#890)

See merge request !866
parents aee2c44a 0600819b
......@@ -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)
......
......@@ -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"),
},
)
)
......
......@@ -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"]
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
......@@ -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")
......
......@@ -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,
......
......@@ -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"]
......
......@@ -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)
# 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')),
],
),
]
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
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": {