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

moderator interface for reports (#890)

See merge request funkwhale/funkwhale!866
This commit is contained in:
Eliot Berriot 2019-09-06 10:14:09 +02:00
commit bc39b18173
48 changed files with 2164 additions and 43 deletions

View File

@ -57,6 +57,64 @@ class SmartSearchFilter(django_filters.CharFilter):
return search.apply(qs, cleaned) 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): class GenericRelation(serializers.JSONField):
def __init__(self, choices, *args, **kwargs): def __init__(self, choices, *args, **kwargs):
self.choices = choices self.choices = choices
@ -68,14 +126,16 @@ class GenericRelation(serializers.JSONField):
return return
type = None type = None
id = None id = None
id_attr = None
for key, choice in self.choices.items(): for key, choice in self.choices.items():
if isinstance(value, choice["queryset"].model): if isinstance(value, choice["queryset"].model):
type = key type = key
id = getattr(value, choice.get("id_attr", "id")) id_attr = choice.get("id_attr", "id")
id = getattr(value, id_attr)
break break
if type: if type:
return {"type": type, "id": id} return {"type": type, id_attr: id}
def to_internal_value(self, v): def to_internal_value(self, v):
v = super().to_internal_value(v) v = super().to_internal_value(v)

View File

@ -15,7 +15,7 @@ class NoneObject(object):
NONE = NoneObject() NONE = NoneObject()
NULL_BOOLEAN_CHOICES = [ BOOLEAN_CHOICES = [
(True, True), (True, True),
("true", True), ("true", True),
("True", True), ("True", True),
@ -26,6 +26,8 @@ NULL_BOOLEAN_CHOICES = [
("False", False), ("False", False),
("0", False), ("0", False),
("no", False), ("no", False),
]
NULL_BOOLEAN_CHOICES = BOOLEAN_CHOICES + [
("None", NONE), ("None", NONE),
("none", NONE), ("none", NONE),
("Null", NONE), ("Null", NONE),
@ -76,10 +78,26 @@ def clean_null_boolean_filter(v):
return v return v
def clean_boolean_filter(v):
return CoerceChoiceField(choices=BOOLEAN_CHOICES).clean(v)
def get_null_boolean_filter(name): def get_null_boolean_filter(name):
return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} 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): class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField):
def valid_value(self, value): def valid_value(self, value):
return True return True
@ -142,7 +160,7 @@ class MutationFilter(filters.FilterSet):
"domain": {"to": "created_by__domain__name__iexact"}, "domain": {"to": "created_by__domain__name__iexact"},
"is_approved": get_null_boolean_filter("is_approved"), "is_approved": get_null_boolean_filter("is_approved"),
"target": {"handler": filter_target}, "target": {"handler": filter_target},
"is_applied": {"to": "is_applied"}, "is_applied": get_boolean_filter("is_applied"),
}, },
) )
) )

View File

@ -5,11 +5,14 @@ import django_filters
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import filters as common_filters
from funkwhale_api.common import search from funkwhale_api.common import search
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models 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 models as music_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
from funkwhale_api.tags import models as tags_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 get_actor_filter(actor_field):
def handler(v): 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} 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: class Meta:
model = moderation_models.InstancePolicy model = moderation_models.InstancePolicy
fields = [ fields = [
@ -330,6 +337,9 @@ class ManageInstancePolicyFilterSet(filters.FilterSet):
"silence_activity", "silence_activity",
"silence_notifications", "silence_notifications",
"reject_media", "reject_media",
"target_domain",
"target_account_domain",
"target_account_username",
] ]
@ -339,3 +349,48 @@ class ManageTagFilterSet(filters.FilterSet):
class Meta: class Meta:
model = tags_models.Tag model = tags_models.Tag
fields = ["q"] 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"]

View File

@ -1,13 +1,17 @@
from django.conf import settings
from django.db import transaction from django.db import transaction
from rest_framework import serializers 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 serializers as common_serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import fields as federation_fields from funkwhale_api.federation import fields as federation_fields
from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.moderation import models as moderation_models 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 models as music_models
from funkwhale_api.music import serializers as music_serializers from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import models as tags_models
@ -182,6 +186,8 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer):
class ManageBaseActorSerializer(serializers.ModelSerializer): class ManageBaseActorSerializer(serializers.ModelSerializer):
is_local = serializers.SerializerMethodField()
class Meta: class Meta:
model = federation_models.Actor model = federation_models.Actor
fields = [ fields = [
@ -200,9 +206,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
"outbox_url", "outbox_url",
"shared_inbox_url", "shared_inbox_url",
"manually_approves_followers", "manually_approves_followers",
"is_local",
] ]
read_only_fields = ["creation_date", "instance_policy"] read_only_fields = ["creation_date", "instance_policy"]
def get_is_local(self, o):
return o.domain_id == settings.FEDERATION_HOSTNAME
class ManageActorSerializer(ManageBaseActorSerializer): class ManageActorSerializer(ManageBaseActorSerializer):
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
@ -629,3 +639,64 @@ class ManageTagActionSerializer(common_serializers.ActionSerializer):
@transaction.atomic @transaction.atomic
def handle_delete(self, objects): def handle_delete(self, objects):
return objects.delete() 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

View File

@ -17,6 +17,8 @@ moderation_router = routers.OptionalSlashRouter()
moderation_router.register( moderation_router.register(
r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" 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 = routers.OptionalSlashRouter()
users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"users", views.ManageUserViewSet, "users")

View File

@ -459,6 +459,60 @@ class ManageInstancePolicyViewSet(
serializer.save(actor=self.request.user.actor) 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( class ManageTagViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,

View File

@ -30,6 +30,22 @@ class InstancePolicyAdmin(admin.ModelAdmin):
list_select_related = True 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) @admin.register(models.UserFilter)
class UserFilterAdmin(admin.ModelAdmin): class UserFilterAdmin(admin.ModelAdmin):
list_display = ["uuid", "user", "target_artist", "creation_date"] list_display = ["uuid", "user", "target_artist", "creation_date"]

View File

@ -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.music import factories as music_factories
from funkwhale_api.users import factories as users_factories from funkwhale_api.users import factories as users_factories
from . import serializers
@registry.register @registry.register
class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): 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 @registry.register
class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory) submitter = factory.SubFactory(federation_factories.ActorFactory)
target = None target = factory.SubFactory(music_factories.ArtistFactory)
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
type = "other" type = "other"
@ -51,3 +63,13 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class Params: class Params:
anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) 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)

View File

@ -0,0 +1,30 @@
# Generated by Django 2.2.4 on 2019-08-29 09:08
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0020_auto_20190730_0846'),
('contenttypes', '0002_remove_content_type_name'),
('moderation', '0003_report'),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('summary', models.TextField(max_length=50000)),
('target_id', models.IntegerField(null=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_notes', to='federation.Actor')),
('target_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
),
]

View File

@ -1,11 +1,12 @@
import urllib.parse import urllib.parse
import uuid import uuid
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.db import models 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.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -147,6 +148,10 @@ class Report(federation_models.FederationMixin):
# delete # delete
target_state = JSONField(null=True) target_state = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
def get_federation_id(self): def get_federation_id(self):
if self.fid: if self.fid:
return self.fid return self.fid
@ -160,3 +165,26 @@ class Report(federation_models.FederationMixin):
self.fid = self.get_federation_id() self.fid = self.get_federation_id()
return super().save(**kwargs) 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

View File

@ -1,5 +1,9 @@
import persisting_theory import json
import urllib.parse
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
import persisting_theory
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import fields as common_fields from funkwhale_api.common import fields as common_fields
@ -117,7 +121,15 @@ class TrackStateSerializer(serializers.ModelSerializer):
class LibraryStateSerializer(serializers.ModelSerializer): class LibraryStateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = music_models.Library 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") @state_serializers.register(name="playlists.Playlist")
@ -135,6 +147,7 @@ class ActorStateSerializer(serializers.ModelSerializer):
"fid", "fid",
"name", "name",
"preferred_username", "preferred_username",
"full_username",
"summary", "summary",
"domain", "domain",
"type", "type",
@ -160,26 +173,28 @@ def get_target_owner(target):
return mapping[target.__class__](target) return mapping[target.__class__](target)
TARGET_CONFIG = {
"artist": {"queryset": music_models.Artist.objects.all()},
"album": {"queryset": music_models.Album.objects.all()},
"track": {"queryset": music_models.Track.objects.all()},
"library": {
"queryset": music_models.Library.objects.all(),
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"playlist": {"queryset": playlists_models.Playlist.objects.all()},
"account": {
"queryset": federation_models.Actor.objects.all(),
"id_attr": "full_username",
"id_field": serializers.EmailField(),
"get_query": get_actor_query,
},
}
TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG)
class ReportSerializer(serializers.ModelSerializer): class ReportSerializer(serializers.ModelSerializer):
target = common_fields.GenericRelation( target = TARGET_FIELD
{
"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: class Meta:
model = models.Report model = models.Report
@ -225,5 +240,21 @@ class ReportSerializer(serializers.ModelSerializer):
validated_data["target_state"] = target_state_serializer( validated_data["target_state"] = target_state_serializer(
validated_data["target"] validated_data["target"]
).data ).data
# freeze target type/id in JSON so even if the corresponding object is deleted
# we can have the info and display it in the frontend
target_data = self.fields["target"].to_representation(validated_data["target"])
validated_data["target_state"]["_target"] = json.loads(
json.dumps(target_data, cls=DjangoJSONEncoder)
)
if "fid" in validated_data["target_state"]:
validated_data["target_state"]["domain"] = urllib.parse.urlparse(
validated_data["target_state"]["fid"]
).hostname
validated_data["target_state"]["is_local"] = (
validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME)
== settings.FEDERATION_HOSTNAME
)
validated_data["target_owner"] = get_target_owner(validated_data["target"]) validated_data["target_owner"] = get_target_owner(validated_data["target"])
return super().create(validated_data) return super().create(validated_data)

View File

@ -0,0 +1,21 @@
from rest_framework import serializers
from funkwhale_api.federation import models as federation_models
from . import models
from . import serializers as moderation_serializers
NOTE_TARGET_FIELDS = {
"report": {
"queryset": models.Report.objects.all(),
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"account": {
"queryset": federation_models.Actor.objects.all(),
"id_attr": "full_username",
"id_field": serializers.EmailField(),
"get_query": moderation_serializers.get_actor_query,
},
}

View File

@ -46,6 +46,10 @@ PERMISSIONS_CONFIGURATION = {
"write:instance:accounts", "write:instance:accounts",
"read:instance:domains", "read:instance:domains",
"write:instance:domains", "write:instance:domains",
"read:instance:reports",
"write:instance:reports",
"read:instance:notes",
"write:instance:notes",
}, },
}, },
"library": { "library": {

View File

@ -34,6 +34,8 @@ BASE_SCOPES = [
Scope("instance:accounts", "Access instance federated accounts"), Scope("instance:accounts", "Access instance federated accounts"),
Scope("instance:domains", "Access instance domains"), Scope("instance:domains", "Access instance domains"),
Scope("instance:policies", "Access instance moderation policies"), Scope("instance:policies", "Access instance moderation policies"),
Scope("instance:reports", "Access instance moderation reports"),
Scope("instance:notes", "Access instance moderation notes"),
] ]
SCOPES = [ SCOPES = [
Scope("read", children=[s.copy("read") for s in BASE_SCOPES]), Scope("read", children=[s.copy("read") for s in BASE_SCOPES]),

View File

@ -67,3 +67,39 @@ def test_generic_relation_field_validation_error(payload, expected_error, factor
with pytest.raises(fields.serializers.ValidationError, match=expected_error): with pytest.raises(fields.serializers.ValidationError, match=expected_error):
f.to_internal_value(payload) f.to_internal_value(payload)
def test_generic_relation_filter_target_type(factories):
user = factories["users.User"]()
note = factories["moderation.Note"](target=user)
factories["moderation.Note"](target=factories["music.Artist"]())
f = fields.GenericRelationFilter(
"target",
{
"user": {
"queryset": user.__class__.objects.all(),
"id_attr": "username",
"id_field": fields.serializers.CharField(),
}
},
)
qs = f.filter(note.__class__.objects.all(), "user")
assert list(qs) == [note]
def test_generic_relation_filter_target_type_and_id(factories):
user = factories["users.User"]()
note = factories["moderation.Note"](target=user)
factories["moderation.Note"](target=factories["users.User"]())
f = fields.GenericRelationFilter(
"target",
{
"user": {
"queryset": user.__class__.objects.all(),
"id_attr": "username",
"id_field": fields.serializers.CharField(),
}
},
)
qs = f.filter(note.__class__.objects.all(), "user:{}".format(user.username))
assert list(qs) == [note]

View File

@ -87,6 +87,7 @@ def test_manage_actor_serializer(factories, now, to_api_date):
"full_username": actor.full_username, "full_username": actor.full_username,
"user": None, "user": None,
"instance_policy": None, "instance_policy": None,
"is_local": False,
} }
s = serializers.ManageActorSerializer(actor) s = serializers.ManageActorSerializer(actor)
@ -521,3 +522,49 @@ def test_manage_tag_serializer(factories, to_api_date):
s = serializers.ManageTagSerializer(tag) s = serializers.ManageTagSerializer(tag)
assert s.data == expected assert s.data == expected
def test_manage_report_serializer(factories, to_api_date):
artist = factories["music.Artist"](attributed=True)
report = factories["moderation.Report"](
target=artist, target_state={"hello": "world"}, assigned=True
)
expected = {
"id": report.id,
"uuid": str(report.uuid),
"fid": report.fid,
"creation_date": to_api_date(report.creation_date),
"handled_date": None,
"summary": report.summary,
"is_handled": report.is_handled,
"type": report.type,
"submitter_email": None,
"submitter": serializers.ManageBaseActorSerializer(report.submitter).data,
"assigned_to": serializers.ManageBaseActorSerializer(report.assigned_to).data,
"target": {"type": "artist", "id": artist.pk},
"target_owner": serializers.ManageBaseActorSerializer(
artist.attributed_to
).data,
"target_state": report.target_state,
"notes": [],
}
s = serializers.ManageReportSerializer(report)
assert s.data == expected
def test_manage_note_serializer(factories, to_api_date):
actor = factories["federation.Actor"]()
note = factories["moderation.Note"](target=actor)
expected = {
"id": note.id,
"uuid": str(note.uuid),
"summary": note.summary,
"creation_date": to_api_date(note.creation_date),
"author": serializers.ManageBaseActorSerializer(note.author).data,
"target": {"type": "account", "full_username": actor.full_username},
}
s = serializers.ManageNoteSerializer(note)
assert s.data == expected

View File

@ -391,6 +391,50 @@ def test_upload_delete(factories, superuser_api_client):
assert response.status_code == 204 assert response.status_code == 204
def test_note_create(factories, superuser_api_client):
actor = superuser_api_client.user.create_actor()
target = factories["federation.Actor"]()
data = {
"summary": "Hello",
"target": {"type": "account", "full_username": target.full_username},
}
url = reverse("api:v1:manage:moderation:notes-list")
response = superuser_api_client.post(url, data, format="json")
assert response.status_code == 201
note = actor.moderation_notes.latest("id")
assert note.target == target
assert response.data == serializers.ManageNoteSerializer(note).data
def test_note_list(factories, superuser_api_client, settings):
note = factories["moderation.Note"]()
url = reverse("api:v1:manage:moderation:notes-list")
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0] == serializers.ManageNoteSerializer(note).data
def test_note_delete(factories, superuser_api_client):
note = factories["moderation.Note"]()
url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid})
response = superuser_api_client.delete(url)
assert response.status_code == 204
def test_note_detail(factories, superuser_api_client):
note = factories["moderation.Note"]()
url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid})
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data == serializers.ManageNoteSerializer(note).data
def test_tag_detail(factories, superuser_api_client): def test_tag_detail(factories, superuser_api_client):
tag = factories["tags.Tag"]() tag = factories["tags.Tag"]()
url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name})
@ -417,3 +461,37 @@ def test_tag_delete(factories, superuser_api_client):
response = superuser_api_client.delete(url) response = superuser_api_client.delete(url)
assert response.status_code == 204 assert response.status_code == 204
def test_report_detail(factories, superuser_api_client):
report = factories["moderation.Report"]()
url = reverse(
"api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid}
)
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["summary"] == report.summary
def test_report_list(factories, superuser_api_client, settings):
report = factories["moderation.Report"]()
url = reverse("api:v1:manage:moderation:reports-list")
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["summary"] == report.summary
def test_report_update(factories, superuser_api_client):
report = factories["moderation.Report"]()
url = reverse(
"api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid}
)
response = superuser_api_client.patch(url, {"is_handled": True})
assert response.status_code == 200
report.refresh_from_db()
assert report.is_handled is True

View File

@ -0,0 +1,22 @@
def test_setting_report_handled_to_true_sets_handled_date(factories, now):
target = factories["music.Artist"]()
report = factories["moderation.Report"](target=target)
assert report.is_handled is False
assert report.handled_date is None
report.is_handled = True
report.save()
assert report.handled_date == now
def test_setting_report_handled_to_false_sets_handled_date_to_null(factories, now):
target = factories["music.Artist"]()
report = factories["moderation.Report"](
target=target, is_handled=True, handled_date=now
)
report.is_handled = False
report.save()
assert report.handled_date is None

View File

@ -1,4 +1,8 @@
import json
import pytest import pytest
import urllib.parse
from django.core.serializers.json import DjangoJSONEncoder
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
@ -41,7 +45,6 @@ def test_user_filter_serializer_save(factories):
("music.Album", "album", "id", serializers.AlbumStateSerializer), ("music.Album", "album", "id", serializers.AlbumStateSerializer),
("music.Track", "track", "id", serializers.TrackStateSerializer), ("music.Track", "track", "id", serializers.TrackStateSerializer),
("music.Library", "library", "uuid", serializers.LibraryStateSerializer), ("music.Library", "library", "uuid", serializers.LibraryStateSerializer),
("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer),
( (
"federation.Actor", "federation.Actor",
"account", "account",
@ -50,8 +53,8 @@ def test_user_filter_serializer_save(factories):
), ),
], ],
) )
def test_report_serializer_save( def test_report_federated_entity_serializer_save(
factory_name, target_type, id_field, state_serializer, factories, mocker factory_name, target_type, id_field, state_serializer, factories, mocker, settings
): ):
target = factories[factory_name]() target = factories[factory_name]()
target_owner = factories["federation.Actor"]() target_owner = factories["federation.Actor"]()
@ -72,10 +75,58 @@ def test_report_serializer_save(
report = serializer.save() report = serializer.save()
expected_state = state_serializer(target).data
expected_state["_target"] = json.loads(
json.dumps(target_data, cls=DjangoJSONEncoder)
)
expected_state["domain"] = urllib.parse.urlparse(target.fid).hostname
expected_state["is_local"] = (
expected_state["domain"] == settings.FEDERATION_HOSTNAME
)
assert report.target == target assert report.target == target
assert report.type == payload["type"] assert report.type == payload["type"]
assert report.summary == payload["summary"] assert report.summary == payload["summary"]
assert report.target_state == state_serializer(target).data assert report.target_state == expected_state
assert report.target_owner == target_owner
get_target_owner.assert_called_once_with(target)
@pytest.mark.parametrize(
"factory_name, target_type, id_field, state_serializer",
[("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer)],
)
def test_report_local_entity_serializer_save(
factory_name, target_type, id_field, state_serializer, factories, mocker, settings
):
target = factories[factory_name]()
target_owner = factories["federation.Actor"]()
submitter = factories["federation.Actor"]()
target_data = {"type": target_type, id_field: getattr(target, id_field)}
payload = {
"summary": "Report content",
"type": "illegal_content",
"target": target_data,
}
serializer = serializers.ReportSerializer(
data=payload, context={"submitter": submitter}
)
get_target_owner = mocker.patch.object(
serializers, "get_target_owner", return_value=target_owner
)
assert serializer.is_valid(raise_exception=True) is True
report = serializer.save()
expected_state = state_serializer(target).data
expected_state["_target"] = json.loads(
json.dumps(target_data, cls=DjangoJSONEncoder)
)
expected_state["is_local"] = True
assert report.target == target
assert report.type == payload["type"]
assert report.summary == payload["summary"]
assert report.target_state == expected_state
assert report.target_owner == target_owner assert report.target_owner == target_owner
get_target_owner.assert_called_once_with(target) get_target_owner.assert_called_once_with(target)

View File

@ -50,6 +50,10 @@ from funkwhale_api.users.oauth import scopes
"write:instance:edits", "write:instance:edits",
"read:instance:libraries", "read:instance:libraries",
"write:instance:libraries", "write:instance:libraries",
"read:instance:notes",
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
}, },
), ),
( (
@ -91,6 +95,10 @@ from funkwhale_api.users.oauth import scopes
"write:instance:users", "write:instance:users",
"read:instance:invitations", "read:instance:invitations",
"write:instance:invitations", "write:instance:invitations",
"read:instance:notes",
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
}, },
), ),
( (
@ -126,6 +134,10 @@ from funkwhale_api.users.oauth import scopes
"write:instance:accounts", "write:instance:accounts",
"read:instance:domains", "read:instance:domains",
"write:instance:domains", "write:instance:domains",
"read:instance:notes",
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
}, },
), ),
( (

View File

@ -15,14 +15,15 @@
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
"diff": "^4.0.1", "diff": "^4.0.1",
"django-channels": "^1.1.6", "django-channels": "^1.1.6",
"fomantic-ui-css": "^2.7",
"howler": "^2.0.14", "howler": "^2.0.14",
"js-logger": "^1.4.1", "js-logger": "^1.4.1",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"lodash": "^4.17.10", "lodash": "^4.17.10",
"masonry-layout": "^4.2.2", "masonry-layout": "^4.2.2",
"moment": "^2.22.2", "moment": "^2.22.2",
"fomantic-ui-css": "^2.7",
"qs": "^6.7.0", "qs": "^6.7.0",
"sanitize-html": "^1.20.1",
"showdown": "^1.8.6", "showdown": "^1.8.6",
"vue": "^2.5.17", "vue": "^2.5.17",
"vue-gettext": "^2.1.0", "vue-gettext": "^2.1.0",

View File

@ -93,8 +93,12 @@
<router-link <router-link
v-if="$store.state.auth.availablePermissions['moderation']" v-if="$store.state.auth.availablePermissions['moderation']"
class="item" class="item"
:to="{name: 'manage.moderation.domains.list'}"> :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
<i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate>
<div
v-if="$store.state.ui.notifications.pendingReviewReports > 0"
:title="labels.pendingReviewReports"
:class="['ui', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
</router-link> </router-link>
<router-link <router-link
v-if="$store.state.auth.availablePermissions['settings']" v-if="$store.state.auth.availablePermissions['settings']"

View File

@ -0,0 +1,45 @@
<template>
<span class="feedback" v-if="isLoading || isDone">
<span v-if="isLoading" :class="['ui', 'active', size, 'inline', 'loader']"></span>
<i v-if="isDone" :class="['green', size, 'check', 'icon']"></i>
</span>
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
export default {
props: {
isLoading: {type: Boolean, required: true},
size: {type: String, default: 'small'},
},
data () {
return {
timer: null,
isDone: false,
}
},
destroyed () {
if (this.timer) {
clearTimeout(this.timer)
}
},
watch: {
isLoading (v) {
let self = this
if (v && this.timer) {
clearTimeout(this.timer)
}
if (v) {
this.isDone = false
} else {
this.isDone = true
this.timer = setTimeout(() => {
self.isDone = false
}, (2000));
}
}
}
}
</script>

View File

@ -1,5 +1,9 @@
<template> <template>
<span :title="actor.full_username"> <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }}
</router-link>
<span v-else :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" /> <actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }} &nbsp;{{ actor.full_username | truncate(30) }}
</span> </span>
@ -11,7 +15,8 @@ import {hashCode, intToRGB} from '@/utils/color'
export default { export default {
props: { props: {
actor: {type: Object}, actor: {type: Object},
avatar: {type: Boolean, default: true} avatar: {type: Boolean, default: true},
admin: {type: Boolean, default: false},
} }
} }
</script> </script>

View File

@ -0,0 +1,20 @@
<template>
<a role="button" class="collapse link" @click.prevent="$emit('input', !value)">
<translate v-if="isCollapsed" key="1" translate-context="*/*/Button,Label">Expand</translate>
<translate v-else key="2" translate-context="*/*/Button,Label">Collapse</translate>
<i :class="[{down: !isCollapsed}, {right: isCollapsed}, 'angle', 'icon']"></i>
</a>
</template>
<script>
export default {
props: {
value: {type: Boolean, required: true},
},
computed: {
isCollapsed () {
return this.value
}
}
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<div class="expandable-wrapper">
<div :class="['expandable-content', {expandable: truncated.length < content.length}, {expanded: isExpanded}]">
<slot>{{ content }}</slot>
</div>
<a v-if="truncated.length < content.length" role="button" @click.prevent="isExpanded = !isExpanded">
<br>
<translate v-if="isExpanded" key="1" translate-context="*/*/Button,Label">Show less</translate>
<translate v-else key="2" translate-context="*/*/Button,Label">Show more</translate>
</a>
</div>
</template>
<script>
import sanitize from "@/sanitize"
export default {
props: {
content: {type: String, required: true},
length: {type: Number, default: 150, required: false},
},
data () {
return {
isExpanded: false,
}
},
computed: {
truncated () {
return this.content.substring(0, this.length)
}
}
}
</script>

View File

@ -48,4 +48,16 @@ import EmptyState from '@/components/common/EmptyState'
Vue.component('empty-state', EmptyState) Vue.component('empty-state', EmptyState)
import ExpandableDiv from '@/components/common/ExpandableDiv'
Vue.component('expandable-div', ExpandableDiv)
import CollapseLink from '@/components/common/CollapseLink'
Vue.component('collapse-link', CollapseLink)
import ActionFeedback from '@/components/common/ActionFeedback'
Vue.component('action-feedback', ActionFeedback)
export default {} export default {}

View File

@ -158,6 +158,9 @@ export default {
}, },
updatedFields () { updatedFields () {
if (!this.obj.target) {
return []
}
let payload = this.obj.payload let payload = this.obj.payload
let previousState = this.previousState let previousState = this.previousState
let fields = Object.keys(payload) let fields = Object.keys(payload)

View File

@ -0,0 +1,99 @@
<template>
<button class="ui button" @click.prevent="show = !show">
<i class="shield icon"></i>&nbsp;
<slot>
<translate translate-context="Content/Moderation/Button.Label">Moderation rules</translate>
</slot>
<modal :show.sync="show" @show="fetchData">
<div class="header">
<translate :translate-params="{obj: target}" translate-context="Popup/Moderation/Title/Verb">Manage moderation rules for %{ obj }</translate>
</div>
<div class="content">
<div class="description">
<div v-if="isLoading" class="ui active loader"></div>
<instance-policy-card v-else-if="obj && !showForm" :object="obj" @update="showForm = true">
<header class="ui header">
<h3>
<translate translate-context="Content/Moderation/Card.Title">This entity is subject to specific moderation rules</translate>
</h3>
</header>
</instance-policy-card>
<instance-policy-form
v-else
@cancel="showForm = false"
@save="showForm = false; result = {count: 1, results: [$event]}"
@delete="result = {count: 0, results: []}; showForm = false"
:object="obj"
:type="type"
:target="target" />
</div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider"></div>
</div>
<div class="actions">
<div class="ui deny button">
<translate translate-context="*/*/Button.Label/Verb">Close</translate>
</div>
</div>
</modal>
</button>
</template>
<script>
import axios from 'axios'
import InstancePolicyForm from "@/components/manage/moderation/InstancePolicyForm"
import InstancePolicyCard from "@/components/manage/moderation/InstancePolicyCard"
import Modal from '@/components/semantic/Modal'
export default {
props: {
target: {required: true},
type: {required: true},
},
components: {
InstancePolicyForm,
InstancePolicyCard,
Modal,
},
data () {
return {
show: false,
isLoading: false,
errors: [],
showForm: false,
result: null,
}
},
computed: {
obj () {
if (!this.result) {
return null
}
return this.result.results[0]
}
},
methods: {
fetchData () {
let params = {}
if (this.type === 'domain') {
params.target_domain = this.target
}
if (this.type === 'actor') {
let parts = this.target.split('@')
params.target_account_username = parts[0]
params.target_account_domain = parts[1]
}
let self = this
self.isLoading = true
axios.get('/manage/moderation/instance-policies/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
}
}
</script>

View File

@ -0,0 +1,60 @@
<template>
<form class="ui form" @submit.prevent="submit()">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/Moderation/Error message.Title">Error while submitting note</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="field">
<textarea name="change-summary" required v-model="summary" id="change-summary" rows="3" :placeholder="labels.summaryPlaceholder"></textarea>
</div>
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
<translate translate-context="Content/Moderation/Button.Label/Verb">Add note</translate>
</button>
</form>
</template>
<script>
import axios from 'axios'
import showdown from 'showdown'
export default {
props: {
target: {required: true},
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
summary: '',
errors: [],
}
},
computed: {
labels () {
return {
summaryPlaceholder: this.$pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…'),
}
},
},
methods: {
submit () {
let self = this
this.isLoading = true
let payload = {
target: this.target,
summary: this.summary
}
axios.post(`manage/moderation/notes/`, payload).then((response) => {
self.$emit('created', response.data)
self.summary = ''
self.isLoading = false
}, error => {
self.errors = error.backendErrors
self.isLoading = false
})
},
}
}
</script>

View File

@ -0,0 +1,65 @@
<template>
<div class="ui feed">
<div class="event" v-for="note in notes" :key="note.uuid">
<div class="label">
<i class="comment outline icon"></i>
</div>
<div class="content">
<div class="summary">
<actor-link :admin="true" :actor="note.author"></actor-link>
<div class="date">
<human-date :date="note.creation_date"></human-date>
</div>
</div>
<div class="extra text">
<expandable-div :content="note.summary">
<div v-html="markdown.makeHtml(note.summary)"></div>
</expandable-div>
</div>
<div class="meta">
<dangerous-button
:class="['ui', {loading: isLoading}, 'basic borderless mini button']"
color="grey"
@confirm="remove(note)">
<i class="trash icon"></i>
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Moderation/Title">Delete this note?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The note will be removed. This action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import showdown from 'showdown'
export default {
props: {
notes: {required: true},
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
}
},
methods: {
remove (obj) {
let self = this
this.isLoading = true
axios.delete(`manage/moderation/notes/${obj.uuid}/`).then((response) => {
self.$emit('deleted', obj.uuid)
self.isLoading = false
}, error => {
self.isLoading = false
})
},
}
}
</script>

View File

@ -0,0 +1,416 @@
<template>
<div class="ui fluid report card">
<div class="content">
<div class="header">
<router-link :to="{name: 'manage.moderation.reports.detail', params: {id: obj.uuid}}">
<translate translate-context="Content/Moderation/Card/Short" :translate-params="{id: obj.uuid.substring(0, 8)}">Report %{ id }</translate>
</router-link>
<collapse-link class="right floated" v-model="isCollapsed"></collapse-link>
</div>
<div class="content">
<div class="ui hidden divider"></div>
<div class="ui stackable two column grid">
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Submitted by</translate>
</td>
<td>
<div v-if="obj.submitter">
<actor-link :admin="true" :actor="obj.submitter" />
</div>
<div v-else="obj.submitter_email">
{{ obj.submitter_email }}
</div>
</td>
</tr>
<tr>
<td>
<translate translate-context="*/*/*">Category</translate>
</td>
<td>
<report-category-dropdown
:value="obj.type"
@input="update({type: $event})">
&#32;
<action-feedback :is-loading="updating.type"></action-feedback>
</report-category-dropdown>
</td>
</tr>
<tr>
<td>
<translate translate-context="*/*/*/Noun">Creation date</translate>
</td>
<td>
<human-date :date="obj.creation_date" :icon="true"></human-date>
</td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="*/*/*">Status</translate>
</td>
<td v-if="obj.is_handled">
<span v-if="obj.is_handled">
<i class="green check icon"></i>
<translate translate-context="Content/*/*/Short">Resolved</translate>
</span>
</td>
<td v-else>
<i class="red x icon"></i>
<translate translate-context="Content/*/*/Short">Unresolved</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Assigned to</translate>
</td>
<td>
<div v-if="obj.assigned_to">
<actor-link :admin="true" :actor="obj.assigned_to" />
</div>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Resolution date</translate>
</td>
<td>
<human-date v-if="obj.handled_date" :date="obj.handled_date" :icon="true"></human-date>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Internal notes</translate>
</td>
<td>
<i class="comment icon"></i>
{{ obj.notes.length }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="main content" v-if="!isCollapsed">
<div class="ui stackable two column grid">
<div class="column">
<h3>
<translate translate-context="Content/*/*/Short">Message</translate>
</h3>
<expandable-div v-if="obj.summary" class="summary" :content="obj.summary">
<div v-html="markdown.makeHtml(obj.summary)"></div>
</expandable-div>
</div>
<aside class="column">
<h3>
<translate translate-context="Content/*/*/Short">Reported object</translate>
</h3>
<div v-if="!obj.target" class="ui warning message">
<translate translate-context="Content/Moderation/Message">The object associated with this report was deleted.</translate>
</div>
<router-link class="ui basic button" v-if="target && configs[target.type].urls.getAdminDetail" :to="configs[target.type].urls.getAdminDetail(obj.target_state)">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<table class="ui very basic unstackable table">
<tbody>
<tr v-if="target">
<td>
<translate translate-context="*/*/*">Type</translate>
</td>
<td colspan="2">
<i :class="[configs[target.type].icon, 'icon']"></i>
<translate translate-context="*/*/*">{{ configs[target.type].label }}</translate>
</td>
</tr>
<tr v-if="obj.target_owner && (!target || target.type !== 'account')">
<td>
<translate translate-context="*/*/*">Owner</translate>
</td>
<td>
<actor-link :admin="true" :actor="obj.target_owner"></actor-link>
</td>
<td>
<instance-policy-modal
v-if="!obj.target_owner.is_local"
class="right floated mini basic" type="actor" :target="obj.target_owner.full_username" />
</td>
</tr>
<tr v-if="target && target.type === 'account'">
<td>
<translate translate-context="*/*/*">Account</translate>
</td>
<td>
<actor-link :admin="true" :actor="obj.target_owner"></actor-link>
</td>
<td>
<instance-policy-modal
v-if="!obj.target_owner.is_local"
class="right floated mini basic" type="actor" :target="obj.target_owner.full_username" />
</td>
</tr>
<tr v-if="obj.target_state.is_local">
<td>
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</td>
<td colspan="2">
<i class="home icon"></i>
<translate translate-context="Content/Moderation/*/Short, Noun">Local</translate>
</td>
</tr>
<tr v-else-if="obj.target_state.domain">
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: obj.target_state.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
{{ obj.target_state.domain }}
</td>
<td>
<instance-policy-modal class="right floated mini basic" type="domain" :target="obj.target_state.domain" />
</td>
</tr>
<tr v-for="field in targetFields" :key="field.id">
<td>{{ field.label }}</td>
<td colspan="2" v-if="field.repr">{{ field.repr }}</td>
<td colspan="2" v-else>
<translate translate-context="*/*/*">N/A</translate>
</td>
</tr>
</tbody>
</table>
</aside>
</div>
<div class="ui stackable two column grid">
<div class="column">
<h3>
<translate translate-context="Content/*/*/Noun">Internal notes</translate>
</h3>
<notes-thread @deleted="handleRemovedNote($event)" :notes="obj.notes" />
<note-form @created="obj.notes.push($event)" :target="{type: 'report', uuid: obj.uuid}" />
</div>
<div class="column">
<h3>
<translate translate-context="*/*/*">Actions</translate>
</h3>
<div class="ui labelled icon basic buttons">
<button
v-if="obj.is_handled === false"
@click="resolve(true)"
:class="['ui', {loading: isLoading}, 'button']">
<i class="green check icon"></i>&nbsp;
<translate translate-context="Content/*/Button.Label/Verb">Resolve</translate>
</button>
<button
v-if="obj.is_handled === true"
@click="resolve(false)"
:class="['ui', {loading: isLoading}, 'button']">
<i class="yellow redo icon"></i>&nbsp;
<translate translate-context="Content/*/Button.Label">Unresolve</translate>
</button>
<template v-for="action in actions">
<dangerous-button
v-if="action.dangerous"
:class="['ui', {loading: isLoading}, 'button']"
color=""
:action="action.handler">
<i :class="[action.iconColor, action.icon, 'icon']"></i>&nbsp;
{{ action.label }}
<p slot="modal-header">{{ action.modalHeader}}</p>
<div slot="modal-content">
<p>{{ action.modalContent }}</p>
</div>
<p slot="modal-confirm">{{ action.modalConfirmLabel }}</p>
</dangerous-button>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import { diffWordsWithSpace } from 'diff'
import NoteForm from '@/components/manage/moderation/NoteForm'
import NotesThread from '@/components/manage/moderation/NotesThread'
import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown'
import InstancePolicyModal from '@/components/manage/moderation/InstancePolicyModal'
import entities from '@/entities'
import {setUpdate} from '@/utils'
import showdown from 'showdown'
function castValue (value) {
if (value === null || value === undefined) {
return ''
}
return String(value)
}
export default {
props: {
obj: {required: true},
currentState: {required: false}
},
components: {
NoteForm,
NotesThread,
ReportCategoryDropdown,
InstancePolicyModal,
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
isCollapsed: false,
updating: {
type: false,
}
}
},
computed: {
configs: entities.getConfigs,
previousState () {
if (this.obj.is_applied) {
// mutation was applied, we use the previous state that is stored
// on the mutation itself
return this.obj.previous_state
}
// mutation is not applied yet, so we use the current state that was
// passed to the component, if any
return this.currentState
},
detailUrl () {
if (!this.target) {
return ''
}
let namespace
let id = this.target.id
if (this.target.type === 'track') {
namespace = 'library.tracks.edit.detail'
}
if (this.target.type === 'album') {
namespace = 'library.albums.edit.detail'
}
if (this.target.type === 'artist') {
namespace = 'library.artists.edit.detail'
}
return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href
},
targetFields () {
if (!this.target) {
return []
}
let payload = this.obj.target_state
let fields = this.configs[this.target.type].moderatedFields
let self = this
return fields.map((fieldConfig) => {
let dummyRepr = (v) => { return v }
let getValueRepr = fieldConfig.getValueRepr || dummyRepr
let d = {
id: fieldConfig.id,
label: fieldConfig.label,
value: payload[fieldConfig.id],
repr: castValue(getValueRepr(payload[fieldConfig.id])),
}
return d
})
},
target () {
if (this.obj.target) {
return this.obj.target
} else {
return this.obj.target_state._target
}
},
actions () {
if (!this.target) {
return []
}
let self = this
let actions = []
let typeConfig = this.configs[this.target.type]
if (typeConfig.getDeleteUrl) {
let deleteUrl = typeConfig.getDeleteUrl(this.target)
actions.push({
label: this.$pgettext('Content/Moderation/Button/Verb', 'Delete reported object'),
modalHeader: this.$pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'),
modalContent: this.$pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report. This action is irreversible.'),
modalConfirmLabel: this.$pgettext('*/*/*/Verb', 'Delete'),
icon: 'x',
iconColor: 'red',
dangerous: true,
handler: () => {
axios.delete(deleteUrl).then((response) => {
console.log('Target deleted')
self.obj.target = null
}, error => {
console.log('Error while deleting target')
})
}
})
}
return actions
}
},
methods: {
update (payload) {
let url = `manage/moderation/reports/${this.obj.uuid}/`
let self = this
this.isLoading = true
setUpdate(payload, this.updating, true)
axios.patch(url, payload).then((response) => {
self.$emit('updated', payload)
Object.assign(self.obj, payload)
self.isLoading = false
setUpdate(payload, self.updating, false)
}, error => {
self.isLoading = false
setUpdate(payload, self.updating, false)
})
},
resolve (v) {
let url = `manage/moderation/reports/${this.obj.uuid}/`
let self = this
this.isLoading = true
axios.patch(url, {is_handled: v}).then((response) => {
self.$emit('handled', v)
self.isLoading = false
self.obj.is_handled = v
let increment
if (v) {
self.isCollapsed = true
increment = -1
} else {
increment = 1
}
self.$store.commit('ui/incrementNotifications', {count: increment, type: 'pendingReviewReports'})
}, error => {
self.isLoading = false
})
},
handleRemovedNote (uuid) {
this.obj.notes = this.obj.notes.filter((note) => {
return note.uuid != uuid
})
},
}
}
</script>

View File

@ -39,6 +39,16 @@ export default {
}, },
} }
}, },
report_type: {
label: this.$pgettext('*/*/*', 'Category'),
choices: {
takedown_request: this.$pgettext("Content/Moderation/Dropdown", "Takedown request"),
invalid_metadata: this.$pgettext("Content/Moderation/Dropdown", "Invalid metadata"),
illegal_content: this.$pgettext("Content/Moderation/Dropdown", "Illegal content"),
offensive_content: this.$pgettext("Content/Moderation/Dropdown", "Offensive content"),
other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
},
},
}, },
filters: { filters: {
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),

View File

@ -0,0 +1,39 @@
<template>
<div>
<label v-if="label"><translate translate-context="*/*/*">Category</translate></label>
<select class="ui dropdown" :value="value" @change="$emit('input', $event.target.value)">
<option :value="option.value" v-for="option in allCategories">{{ option.label }}</option>
</select>
<slot></slot>
</div>
</template>
<script>
import TranslationsMixin from '@/components/mixins/Translations'
import lodash from '@/lodash'
export default {
mixins: [TranslationsMixin],
props: ['value', 'all', 'label'],
computed: {
allCategories () {
let c = []
if (this.all) {
c.push(
{
value: '',
label: this.$pgettext('Content/*/Dropdown', 'All')
},
)
}
return c.concat(
lodash.keys(this.sharedLabels.fields.report_type.choices).sort().map((v) => {
return {
value: v,
label: this.sharedLabels.fields.report_type.choices[v]
}
})
)
}
}
}
</script>

View File

@ -46,9 +46,11 @@ export default {
handler (newValue) { handler (newValue) {
if (newValue) { if (newValue) {
this.initModal() this.initModal()
this.$emit('show')
this.control.modal('show') this.control.modal('show')
} else { } else {
if (this.control) { if (this.control) {
this.$emit('hide')
this.control.modal('hide') this.control.modal('hide')
this.control.remove() this.control.remove()
} }

213
front/src/entities.js Normal file
View File

@ -0,0 +1,213 @@
function getTagsValueRepr (val) {
if (!val) {
return ''
}
return val.slice().sort().join('\n')
}
export default {
getConfigs () {
return {
artist: {
label: this.$pgettext('*/*/*', 'Artist'),
icon: 'users',
getDeleteUrl: (obj) => {
return `manage/library/artists/${obj.id}/`
},
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.artists.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'creation_date',
label: this.$pgettext('*/*/*/Noun', 'Creation date'),
getValue: (obj) => { return obj.creation_date }
},
{
id: 'tags',
type: 'tags',
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
album: {
label: this.$pgettext('*/*/*', 'Album'),
icon: 'play',
getDeleteUrl: (obj) => {
return `manage/library/albums/${obj.id}/`
},
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.albums.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'title',
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
{
id: 'creation_date',
label: this.$pgettext('*/*/*/Noun', 'Creation date'),
getValue: (obj) => { return obj.creation_date }
},
{
id: 'release_date',
label: this.$pgettext('Content/*/*/Noun', 'Release date'),
getValue: (obj) => { return obj.release_date }
},
{
id: 'tags',
type: 'tags',
required: true,
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
track: {
label: this.$pgettext('*/*/*', 'Track'),
icon: 'music',
getDeleteUrl: (obj) => {
return `manage/library/tracks/${obj.id}/`
},
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.tracks.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'title',
label: this.$pgettext('*/*/*/Noun', 'Title'),
getValue: (obj) => { return obj.title }
},
{
id: 'position',
label: this.$pgettext('*/*/*/Short, Noun', 'Position'),
getValue: (obj) => { return obj.position }
},
{
id: 'copyright',
label: this.$pgettext('Content/Track/*/Noun', 'Copyright'),
getValue: (obj) => { return obj.copyright }
},
{
id: 'license',
label: this.$pgettext('Content/*/*/Noun', 'License'),
getValue: (obj) => { return obj.license },
},
{
id: 'tags',
label: this.$pgettext('*/*/*/Noun', 'Tags'),
getValue: (obj) => { return obj.tags },
getValueRepr: getTagsValueRepr
},
{
id: 'mbid',
label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'),
getValue: (obj) => { return obj.mbid }
},
]
},
library: {
label: this.$pgettext('*/*/*', 'Library'),
icon: 'book',
getDeleteUrl: (obj) => {
return `manage/library/libraries/${obj.uuid}/`
},
urls: {
getAdminDetail: (obj) => { return {name: 'manage.library.libraries.detail', params: {id: obj.uuid}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'description',
label: this.$pgettext('*/*/*/Noun', 'Description'),
getValue: (obj) => { return obj.position }
},
{
id: 'privacy_level',
label: this.$pgettext('*/*/*', 'Visibility'),
getValue: (obj) => { return obj.privacy_level }
},
]
},
playlist: {
label: this.$pgettext('*/*/*', 'Playlist'),
icon: 'list',
urls: {
// getAdminDetail: (obj) => { return {name: 'manage.playlists.detail', params: {id: obj.id}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'privacy_level',
label: this.$pgettext('*/*/*', 'Visibility'),
getValue: (obj) => { return obj.privacy_level }
},
]
},
account: {
label: this.$pgettext('*/*/*', 'Account'),
icon: 'user',
urls: {
getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}}
},
moderatedFields: [
{
id: 'name',
label: this.$pgettext('*/*/*/Noun', 'Name'),
getValue: (obj) => { return obj.name }
},
{
id: 'summary',
label: this.$pgettext('*/*/*/Noun', 'Bio'),
getValue: (obj) => { return obj.summary }
},
]
},
}
},
getConfig () {
return this.configs[this.objectType]
},
getFieldConfig (configs, type, fieldId) {
let c = configs[type]
return c.fields.filter((f) => {
return f.id == fieldId
})[0]
},
getCurrentStateForObj (obj, config) {
let s = {}
config.fields.forEach(f => {
s[f.id] = {value: f.getValue(obj)}
})
return s
},
}

View File

@ -2,6 +2,7 @@
export default { export default {
clone: require('lodash/clone'), clone: require('lodash/clone'),
keys: require('lodash/keys'),
debounce: require('lodash/debounce'), debounce: require('lodash/debounce'),
get: require('lodash/get'), get: require('lodash/get'),
merge: require('lodash/merge'), merge: require('lodash/merge'),

View File

@ -447,7 +447,30 @@ export default new Router({
/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"
), ),
props: true props: true
} },
{
path: "reports",
name: "manage.moderation.reports.list",
component: () =>
import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/ReportsList"
),
props: route => {
return {
defaultQuery: route.query.q,
updateUrl: true
}
}
},
{
path: "reports/:id",
name: "manage.moderation.reports.detail",
component: () =>
import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/ReportDetail"
),
props: true
},
] ]
}, },
{ {

43
front/src/sanitize.js Normal file
View File

@ -0,0 +1,43 @@
import sanitizeHtml from "sanitize-html"
const allowedTags = [
"h3",
"h4",
"h5",
"h6",
"blockquote",
"p",
"a",
"ul",
"ol",
"nl",
"li",
"b",
"i",
"strong",
"em",
"strike",
"code",
"hr",
"br",
"div",
"table",
"thead",
"caption",
"tbody",
"tr",
"th",
"td",
"pre",
]
const allowedAttributes = {
a: ["href", "name", "target"],
// We don't currently allow img itself by default, but this
// would make sense if we did. You could add srcset here,
// and if you do the URL is checked for safety
img: ["src"]
}
export default function sanitize(input) {
return sanitizeHtml(input, {allowedAttributes, allowedAttributes})
}

View File

@ -126,7 +126,12 @@ export default {
resolve(response.data) resolve(response.data)
}) })
dispatch('ui/fetchUnreadNotifications', null, { root: true }) dispatch('ui/fetchUnreadNotifications', null, { root: true })
dispatch('ui/fetchPendingReviewEdits', null, { root: true }) if (response.data.permissions.library) {
dispatch('ui/fetchPendingReviewEdits', null, { root: true })
}
if (response.data.permissions.moderation) {
dispatch('ui/fetchPendingReviewReports', null, { root: true })
}
dispatch('favorites/fetch', null, { root: true }) dispatch('favorites/fetch', null, { root: true })
dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('moderation/fetchContentFilters', null, { root: true })
dispatch('playlists/fetchOwn', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true })

View File

@ -14,6 +14,7 @@ export default {
notifications: { notifications: {
inbox: 0, inbox: 0,
pendingReviewEdits: 0, pendingReviewEdits: 0,
pendingReviewReports: 0,
}, },
websocketEventsHandlers: { websocketEventsHandlers: {
'inbox.item_added': {}, 'inbox.item_added': {},
@ -74,6 +75,11 @@ export default {
commit('notifications', {type: 'pendingReviewEdits', count: response.data.count}) commit('notifications', {type: 'pendingReviewEdits', count: response.data.count})
}) })
}, },
fetchPendingReviewReports ({commit, rootState}, payload) {
axios.get('manage/moderation/reports/', {params: {is_handled: 'false', page_size: 1}}).then((response) => {
commit('notifications', {type: 'pendingReviewReports', count: response.data.count})
})
},
websocketEvent ({state}, event) { websocketEvent ({state}, event) {
let handlers = state.websocketEventsHandlers[event.type] let handlers = state.websocketEventsHandlers[event.type]
console.log('Dispatching websocket event', event, handlers) console.log('Dispatching websocket event', event, handlers)

View File

@ -49,7 +49,7 @@
// @import "~fomantic-ui-css/components/ad.css"; // @import "~fomantic-ui-css/components/ad.css";
@import "~fomantic-ui-css/components/card.css"; @import "~fomantic-ui-css/components/card.css";
// @import "~fomantic-ui-css/components/comment.css"; // @import "~fomantic-ui-css/components/comment.css";
// @import "~fomantic-ui-css/components/feed.css"; @import "~fomantic-ui-css/components/feed.css";
@import "~fomantic-ui-css/components/item.css"; @import "~fomantic-ui-css/components/item.css";
@import "~fomantic-ui-css/components/statistic.css"; @import "~fomantic-ui-css/components/statistic.css";
@ -368,5 +368,20 @@ input + .help {
margin-top: 0.5em; margin-top: 0.5em;
} }
.expandable {
&:not(.expanded) {
overflow: hidden;
max-height: 15vh;
background: linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0.3) 100%);
}
}
.ui.borderless.button {
border: none !important;
box-shadow: none !important;
padding-left: 0;
padding-right: 0;
}
@import "./themes/_light.scss"; @import "./themes/_light.scss";
@import "./themes/_dark.scss"; @import "./themes/_dark.scss";

8
front/src/utils.js Normal file
View File

@ -0,0 +1,8 @@
import lodash from '@/lodash'
export function setUpdate(obj, statuses, value) {
let updatedKeys = lodash.keys(obj)
updatedKeys.forEach((k) => {
statuses[k] = value
})
}

View File

@ -174,6 +174,7 @@
class="ui search selection dropdown"> class="ui search selection dropdown">
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option> <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
</select> </select>
<action-feedback :is-loading="updating.permissions"></action-feedback>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -308,8 +309,9 @@
name="quota" name="quota"
type="number" /> type="number" />
<div class="ui basic label"> <div class="ui basic label">
<translate translate-context="Content/*/*/Unit">MB</translate> <translate translate-context="Content/*/*/Unit">MB</translate>&#32;
</div> </div>
<action-feedback class="ui basic label" size="tiny" :is-loading="updating.upload_quota"></action-feedback>
</div> </div>
</td> </td>
</tr> </tr>
@ -403,6 +405,10 @@ export default {
stats: null, stats: null,
showPolicyForm: false, showPolicyForm: false,
permissions: [], permissions: [],
updating: {
permissions: false,
upload_quota: false,
}
} }
}, },
created() { created() {
@ -457,6 +463,8 @@ export default {
if (toNull && !newValue) { if (toNull && !newValue) {
newValue = null newValue = null
} }
let self = this
this.updating[attr] = true
let params = {} let params = {}
if (attr === "permissions") { if (attr === "permissions") {
params["permissions"] = {} params["permissions"] = {}
@ -471,12 +479,14 @@ export default {
logger.default.info( logger.default.info(
`${attr} was updated succcessfully to ${newValue}` `${attr} was updated succcessfully to ${newValue}`
) )
self.updating[attr] = false
}, },
error => { error => {
logger.default.error( logger.default.error(
`Error while setting ${attr} to ${newValue}`, `Error while setting ${attr} to ${newValue}`,
error error
) )
self.updating[attr] = false
} }
) )
}, },

View File

@ -1,6 +1,9 @@
<template> <template>
<div class="main pusher" v-title="labels.moderation"> <div class="main pusher" v-title="labels.moderation">
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link>
<router-link <router-link
class="ui item" class="ui item"
:to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link> :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>

View File

@ -0,0 +1,46 @@
<template>
<main>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<div class="ui vertical stripe segment">
<report-card :obj="object"></report-card>
</div>
</template>
</main>
</template>
<script>
import axios from "axios"
import ReportCard from "@/components/manage/moderation/ReportCard"
export default {
props: ["id"],
components: {
ReportCard,
},
data() {
return {
isLoading: true,
object: null,
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = `manage/moderation/reports/${this.id}/`
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
})
},
},
}
</script>

View File

@ -0,0 +1,221 @@
<template>
<main v-title="labels.accounts">
<section class="ui vertical stripe segment">
<h2 class="ui header"><translate translate-context="*/Moderation/Title,Name">Reports</translate></h2>
<div class="ui hidden divider"></div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
<form @submit.prevent="search.query = $refs.search.value">
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
</form>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label>
<select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')">
<option value="">
<translate translate-context="Content/*/Dropdown">All</translate>
</option>
<option value="yes">
<translate translate-context="Content/*/*/Short">Resolved</translate>
</option>
<option value="no">
<translate translate-context="Content/*/*/Short">Unresolved</translate>
</option>
</select>
</div>
<report-category-dropdown
class="field"
@input="addSearchToken('category', $event)"
:all="true"
:label="true"
:value="getTokenValue('category', '')"></report-category-dropdown>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
</select>
</div>
</div>
</div>
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<div v-else-if="!result || result.count === 0">
<empty-state @refresh="fetchData()" :refresh="true"></empty-state>
</div>
<div v-else-if="mode === 'card'">
<report-card :obj="obj" v-for="obj in result.results" :key="obj.uuid" />
</div>
</section>
</main>
</template>
<script>
import axios from 'axios'
import _ from '@/lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import OrderingMixin from '@/components/mixins/Ordering'
import TranslationsMixin from '@/components/mixins/Translations'
import ReportCard from '@/components/manage/moderation/ReportCard'
import ReportCategoryDropdown from '@/components/moderation/ReportCategoryDropdown'
import {normalizeQuery, parseTokens} from '@/search'
import SmartSearchMixin from '@/components/mixins/SmartSearch'
export default {
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
components: {
Pagination,
ReportCard,
ReportCategoryDropdown,
},
props: {
mode: {default: 'card'},
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 25,
search: {
query: this.defaultQuery,
tokens: parseTokens(normalizeQuery(this.defaultQuery))
},
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['creation_date', 'creation_date'],
['applied_date', 'applied_date'],
],
targets: {
track: {}
}
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search.query,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
this.result = null
axios.get('manage/moderation/reports/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
// self.fetchTargets()
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
fetchTargets () {
// we request target data via the API so we can display previous state
// additionnal data next to the edit card
let self = this
let typesAndIds = {
track: {
url: 'tracks/',
ids: [],
}
}
this.result.results.forEach((m) => {
if (!m.target || !typesAndIds[m.target.type]) {
return
}
typesAndIds[m.target.type]['ids'].push(m.target.id)
})
Object.keys(typesAndIds).forEach((k) => {
let config = typesAndIds[k]
if (config.ids.length === 0) {
return
}
axios.get(config.url, {params: {id: _.uniq(config.ids), hidden: 'null'}}).then((response) => {
response.data.results.forEach((e) => {
self.$set(self.targets[k], e.id, {
payload: e,
currentState: edits.getCurrentStateForObj(e, edits.getConfigs.bind(self)()[k])
})
})
}, error => {
self.errors = error.backendErrors
})
})
},
selectPage: function (page) {
this.page = page
},
handle (type, id, value) {
if (type === 'delete') {
this.exclude.push(id)
}
this.result.results.forEach((e) => {
if (e.uuid === id) {
e.is_approved = value
}
})
},
getCurrentState (target) {
if (!target) {
return {}
}
if (this.targets[target.type] && this.targets[target.type][String(target.id)]) {
return this.targets[target.type][String(target.id)].currentState
}
return {}
}
},
computed: {
labels () {
return {
searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…'),
reports: this.$pgettext('*/Moderation/Title,Name', "Reports"),
}
},
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -1547,7 +1547,7 @@ array-union@^1.0.1, array-union@^1.0.2:
dependencies: dependencies:
array-uniq "^1.0.1" array-uniq "^1.0.1"
array-uniq@^1.0.1: array-uniq@^1.0.1, array-uniq@^1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
@ -4485,7 +4485,7 @@ html-webpack-plugin@^3.2.0:
toposort "^1.0.0" toposort "^1.0.0"
util.promisify "1.0.0" util.promisify "1.0.0"
htmlparser2@^3.3.0, htmlparser2@^3.8.2, htmlparser2@^3.9.1: htmlparser2@^3.10.0, htmlparser2@^3.3.0, htmlparser2@^3.8.2, htmlparser2@^3.9.1:
version "3.10.1" version "3.10.1"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
@ -5401,16 +5401,36 @@ locate-path@^3.0.0:
p-locate "^3.0.0" p-locate "^3.0.0"
path-exists "^3.0.0" path-exists "^3.0.0"
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
lodash.defaultsdeep@^4.6.0: lodash.defaultsdeep@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz#bec1024f85b1bd96cbea405b23c14ad6443a6f81" resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz#bec1024f85b1bd96cbea405b23c14ad6443a6f81"
integrity sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E= integrity sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E=
lodash.escaperegexp@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=
lodash.get@^4.4.2: lodash.get@^4.4.2:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
lodash.kebabcase@^4.1.1: lodash.kebabcase@^4.1.1:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
@ -5426,6 +5446,11 @@ lodash.memoize@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
lodash.mergewith@^4.6.1:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
lodash.sortby@^4.7.0: lodash.sortby@^4.7.0:
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@ -7806,6 +7831,22 @@ safe-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sanitize-html@^1.20.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.20.1.tgz#f6effdf55dd398807171215a62bfc21811bacf85"
integrity sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA==
dependencies:
chalk "^2.4.1"
htmlparser2 "^3.10.0"
lodash.clonedeep "^4.5.0"
lodash.escaperegexp "^4.1.2"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.mergewith "^4.6.1"
postcss "^7.0.5"
srcset "^1.0.0"
xtend "^4.0.1"
sass-graph@^2.2.4: sass-graph@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
@ -8243,6 +8284,14 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
srcset@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef"
integrity sha1-pWad4StC87HV6D7QPHEEb8SPQe8=
dependencies:
array-uniq "^1.0.2"
number-is-nan "^1.0.0"
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@ -9472,6 +9521,11 @@ xtend@^4.0.0, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68=
xtend@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
y18n@^3.2.1: y18n@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"