Merge branch '890-mods-workflow' into 'develop'
moderator interface for reports (#890) See merge request funkwhale/funkwhale!866
This commit is contained in:
commit
bc39b18173
|
@ -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"]
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import fields as common_fields
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import fields as federation_fields
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.moderation import models as moderation_models
|
||||
from funkwhale_api.moderation import serializers as moderation_serializers
|
||||
from funkwhale_api.moderation import utils as moderation_utils
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import serializers as music_serializers
|
||||
from funkwhale_api.tags import models as tags_models
|
||||
|
@ -182,6 +186,8 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer):
|
|||
|
||||
|
||||
class ManageBaseActorSerializer(serializers.ModelSerializer):
|
||||
is_local = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = federation_models.Actor
|
||||
fields = [
|
||||
|
@ -200,9 +206,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
|
|||
"outbox_url",
|
||||
"shared_inbox_url",
|
||||
"manually_approves_followers",
|
||||
"is_local",
|
||||
]
|
||||
read_only_fields = ["creation_date", "instance_policy"]
|
||||
|
||||
def get_is_local(self, o):
|
||||
return o.domain_id == settings.FEDERATION_HOSTNAME
|
||||
|
||||
|
||||
class ManageActorSerializer(ManageBaseActorSerializer):
|
||||
uploads_count = serializers.SerializerMethodField()
|
||||
|
@ -629,3 +639,64 @@ class ManageTagActionSerializer(common_serializers.ActionSerializer):
|
|||
@transaction.atomic
|
||||
def handle_delete(self, objects):
|
||||
return objects.delete()
|
||||
|
||||
|
||||
class ManageBaseNoteSerializer(serializers.ModelSerializer):
|
||||
author = ManageBaseActorSerializer(required=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = moderation_models.Note
|
||||
fields = ["id", "uuid", "creation_date", "summary", "author"]
|
||||
read_only_fields = ["uuid", "creation_date", "author"]
|
||||
|
||||
|
||||
class ManageNoteSerializer(ManageBaseNoteSerializer):
|
||||
target = common_fields.GenericRelation(moderation_utils.NOTE_TARGET_FIELDS)
|
||||
|
||||
class Meta(ManageBaseNoteSerializer.Meta):
|
||||
fields = ManageBaseNoteSerializer.Meta.fields + ["target"]
|
||||
|
||||
|
||||
class ManageReportSerializer(serializers.ModelSerializer):
|
||||
assigned_to = ManageBaseActorSerializer()
|
||||
target_owner = ManageBaseActorSerializer()
|
||||
submitter = ManageBaseActorSerializer()
|
||||
target = moderation_serializers.TARGET_FIELD
|
||||
notes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = moderation_models.Report
|
||||
fields = [
|
||||
"id",
|
||||
"uuid",
|
||||
"fid",
|
||||
"creation_date",
|
||||
"handled_date",
|
||||
"summary",
|
||||
"type",
|
||||
"target",
|
||||
"target_state",
|
||||
"is_handled",
|
||||
"assigned_to",
|
||||
"target_owner",
|
||||
"submitter",
|
||||
"submitter_email",
|
||||
"notes",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"uuid",
|
||||
"fid",
|
||||
"submitter",
|
||||
"submitter_email",
|
||||
"creation_date",
|
||||
"handled_date",
|
||||
"target",
|
||||
"target_state",
|
||||
"target_owner",
|
||||
"summary",
|
||||
]
|
||||
|
||||
def get_notes(self, o):
|
||||
notes = getattr(o, "_prefetched_notes", [])
|
||||
return ManageBaseNoteSerializer(notes, many=True).data
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,11 +1,12 @@
|
|||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.db import models
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
|
@ -147,6 +148,10 @@ class Report(federation_models.FederationMixin):
|
|||
# delete
|
||||
target_state = JSONField(null=True)
|
||||
|
||||
notes = GenericRelation(
|
||||
"Note", content_type_field="target_content_type", object_id_field="target_id"
|
||||
)
|
||||
|
||||
def get_federation_id(self):
|
||||
if self.fid:
|
||||
return self.fid
|
||||
|
@ -160,3 +165,26 @@ class Report(federation_models.FederationMixin):
|
|||
self.fid = self.get_federation_id()
|
||||
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
summary = models.TextField(max_length=50000)
|
||||
author = models.ForeignKey(
|
||||
"federation.Actor", related_name="moderation_notes", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
target_id = models.IntegerField(null=True)
|
||||
target_content_type = models.ForeignKey(
|
||||
ContentType, null=True, on_delete=models.CASCADE
|
||||
)
|
||||
target = GenericForeignKey("target_content_type", "target_id")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Report)
|
||||
def set_handled_date(sender, instance, **kwargs):
|
||||
if instance.is_handled is True and not instance.handled_date:
|
||||
instance.handled_date = timezone.now()
|
||||
elif not instance.is_handled:
|
||||
instance.handled_date = None
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import persisting_theory
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
import persisting_theory
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import fields as common_fields
|
||||
|
@ -117,7 +121,15 @@ class TrackStateSerializer(serializers.ModelSerializer):
|
|||
class LibraryStateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = music_models.Library
|
||||
fields = ["id", "fid", "name", "description", "creation_date", "privacy_level"]
|
||||
fields = [
|
||||
"id",
|
||||
"uuid",
|
||||
"fid",
|
||||
"name",
|
||||
"description",
|
||||
"creation_date",
|
||||
"privacy_level",
|
||||
]
|
||||
|
||||
|
||||
@state_serializers.register(name="playlists.Playlist")
|
||||
|
@ -135,6 +147,7 @@ class ActorStateSerializer(serializers.ModelSerializer):
|
|||
"fid",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"full_username",
|
||||
"summary",
|
||||
"domain",
|
||||
"type",
|
||||
|
@ -160,26 +173,28 @@ def get_target_owner(target):
|
|||
return mapping[target.__class__](target)
|
||||
|
||||
|
||||
TARGET_CONFIG = {
|
||||
"artist": {"queryset": music_models.Artist.objects.all()},
|
||||
"album": {"queryset": music_models.Album.objects.all()},
|
||||
"track": {"queryset": music_models.Track.objects.all()},
|
||||
"library": {
|
||||
"queryset": music_models.Library.objects.all(),
|
||||
"id_attr": "uuid",
|
||||
"id_field": serializers.UUIDField(),
|
||||
},
|
||||
"playlist": {"queryset": playlists_models.Playlist.objects.all()},
|
||||
"account": {
|
||||
"queryset": federation_models.Actor.objects.all(),
|
||||
"id_attr": "full_username",
|
||||
"id_field": serializers.EmailField(),
|
||||
"get_query": get_actor_query,
|
||||
},
|
||||
}
|
||||
TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG)
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
target = common_fields.GenericRelation(
|
||||
{
|
||||
"artist": {"queryset": music_models.Artist.objects.all()},
|
||||
"album": {"queryset": music_models.Album.objects.all()},
|
||||
"track": {"queryset": music_models.Track.objects.all()},
|
||||
"library": {
|
||||
"queryset": music_models.Library.objects.all(),
|
||||
"id_attr": "uuid",
|
||||
"id_field": serializers.UUIDField(),
|
||||
},
|
||||
"playlist": {"queryset": playlists_models.Playlist.objects.all()},
|
||||
"account": {
|
||||
"queryset": federation_models.Actor.objects.all(),
|
||||
"id_attr": "full_username",
|
||||
"id_field": serializers.EmailField(),
|
||||
"get_query": get_actor_query,
|
||||
},
|
||||
}
|
||||
)
|
||||
target = TARGET_FIELD
|
||||
|
||||
class Meta:
|
||||
model = models.Report
|
||||
|
@ -225,5 +240,21 @@ class ReportSerializer(serializers.ModelSerializer):
|
|||
validated_data["target_state"] = target_state_serializer(
|
||||
validated_data["target"]
|
||||
).data
|
||||
# freeze target type/id in JSON so even if the corresponding object is deleted
|
||||
# we can have the info and display it in the frontend
|
||||
target_data = self.fields["target"].to_representation(validated_data["target"])
|
||||
validated_data["target_state"]["_target"] = json.loads(
|
||||
json.dumps(target_data, cls=DjangoJSONEncoder)
|
||||
)
|
||||
|
||||
if "fid" in validated_data["target_state"]:
|
||||
validated_data["target_state"]["domain"] = urllib.parse.urlparse(
|
||||
validated_data["target_state"]["fid"]
|
||||
).hostname
|
||||
|
||||
validated_data["target_state"]["is_local"] = (
|
||||
validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME)
|
||||
== settings.FEDERATION_HOSTNAME
|
||||
)
|
||||
validated_data["target_owner"] = get_target_owner(validated_data["target"])
|
||||
return super().create(validated_data)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
|
@ -46,6 +46,10 @@ PERMISSIONS_CONFIGURATION = {
|
|||
"write:instance:accounts",
|
||||
"read:instance:domains",
|
||||
"write:instance:domains",
|
||||
"read:instance:reports",
|
||||
"write:instance:reports",
|
||||
"read:instance:notes",
|
||||
"write:instance:notes",
|
||||
},
|
||||
},
|
||||
"library": {
|
||||
|
|
|
@ -34,6 +34,8 @@ BASE_SCOPES = [
|
|||
Scope("instance:accounts", "Access instance federated accounts"),
|
||||
Scope("instance:domains", "Access instance domains"),
|
||||
Scope("instance:policies", "Access instance moderation policies"),
|
||||
Scope("instance:reports", "Access instance moderation reports"),
|
||||
Scope("instance:notes", "Access instance moderation notes"),
|
||||
]
|
||||
SCOPES = [
|
||||
Scope("read", children=[s.copy("read") for s in BASE_SCOPES]),
|
||||
|
|
|
@ -67,3 +67,39 @@ def test_generic_relation_field_validation_error(payload, expected_error, factor
|
|||
|
||||
with pytest.raises(fields.serializers.ValidationError, match=expected_error):
|
||||
f.to_internal_value(payload)
|
||||
|
||||
|
||||
def test_generic_relation_filter_target_type(factories):
|
||||
user = factories["users.User"]()
|
||||
note = factories["moderation.Note"](target=user)
|
||||
factories["moderation.Note"](target=factories["music.Artist"]())
|
||||
f = fields.GenericRelationFilter(
|
||||
"target",
|
||||
{
|
||||
"user": {
|
||||
"queryset": user.__class__.objects.all(),
|
||||
"id_attr": "username",
|
||||
"id_field": fields.serializers.CharField(),
|
||||
}
|
||||
},
|
||||
)
|
||||
qs = f.filter(note.__class__.objects.all(), "user")
|
||||
assert list(qs) == [note]
|
||||
|
||||
|
||||
def test_generic_relation_filter_target_type_and_id(factories):
|
||||
user = factories["users.User"]()
|
||||
note = factories["moderation.Note"](target=user)
|
||||
factories["moderation.Note"](target=factories["users.User"]())
|
||||
f = fields.GenericRelationFilter(
|
||||
"target",
|
||||
{
|
||||
"user": {
|
||||
"queryset": user.__class__.objects.all(),
|
||||
"id_attr": "username",
|
||||
"id_field": fields.serializers.CharField(),
|
||||
}
|
||||
},
|
||||
)
|
||||
qs = f.filter(note.__class__.objects.all(), "user:{}".format(user.username))
|
||||
assert list(qs) == [note]
|
||||
|
|
|
@ -87,6 +87,7 @@ def test_manage_actor_serializer(factories, now, to_api_date):
|
|||
"full_username": actor.full_username,
|
||||
"user": None,
|
||||
"instance_policy": None,
|
||||
"is_local": False,
|
||||
}
|
||||
s = serializers.ManageActorSerializer(actor)
|
||||
|
||||
|
@ -521,3 +522,49 @@ def test_manage_tag_serializer(factories, to_api_date):
|
|||
s = serializers.ManageTagSerializer(tag)
|
||||
|
||||
assert s.data == expected
|
||||
|
||||
|
||||
def test_manage_report_serializer(factories, to_api_date):
|
||||
artist = factories["music.Artist"](attributed=True)
|
||||
report = factories["moderation.Report"](
|
||||
target=artist, target_state={"hello": "world"}, assigned=True
|
||||
)
|
||||
expected = {
|
||||
"id": report.id,
|
||||
"uuid": str(report.uuid),
|
||||
"fid": report.fid,
|
||||
"creation_date": to_api_date(report.creation_date),
|
||||
"handled_date": None,
|
||||
"summary": report.summary,
|
||||
"is_handled": report.is_handled,
|
||||
"type": report.type,
|
||||
"submitter_email": None,
|
||||
"submitter": serializers.ManageBaseActorSerializer(report.submitter).data,
|
||||
"assigned_to": serializers.ManageBaseActorSerializer(report.assigned_to).data,
|
||||
"target": {"type": "artist", "id": artist.pk},
|
||||
"target_owner": serializers.ManageBaseActorSerializer(
|
||||
artist.attributed_to
|
||||
).data,
|
||||
"target_state": report.target_state,
|
||||
"notes": [],
|
||||
}
|
||||
s = serializers.ManageReportSerializer(report)
|
||||
|
||||
assert s.data == expected
|
||||
|
||||
|
||||
def test_manage_note_serializer(factories, to_api_date):
|
||||
actor = factories["federation.Actor"]()
|
||||
note = factories["moderation.Note"](target=actor)
|
||||
|
||||
expected = {
|
||||
"id": note.id,
|
||||
"uuid": str(note.uuid),
|
||||
"summary": note.summary,
|
||||
"creation_date": to_api_date(note.creation_date),
|
||||
"author": serializers.ManageBaseActorSerializer(note.author).data,
|
||||
"target": {"type": "account", "full_username": actor.full_username},
|
||||
}
|
||||
s = serializers.ManageNoteSerializer(note)
|
||||
|
||||
assert s.data == expected
|
||||
|
|
|
@ -391,6 +391,50 @@ def test_upload_delete(factories, superuser_api_client):
|
|||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_note_create(factories, superuser_api_client):
|
||||
actor = superuser_api_client.user.create_actor()
|
||||
target = factories["federation.Actor"]()
|
||||
data = {
|
||||
"summary": "Hello",
|
||||
"target": {"type": "account", "full_username": target.full_username},
|
||||
}
|
||||
url = reverse("api:v1:manage:moderation:notes-list")
|
||||
response = superuser_api_client.post(url, data, format="json")
|
||||
assert response.status_code == 201
|
||||
|
||||
note = actor.moderation_notes.latest("id")
|
||||
assert note.target == target
|
||||
assert response.data == serializers.ManageNoteSerializer(note).data
|
||||
|
||||
|
||||
def test_note_list(factories, superuser_api_client, settings):
|
||||
note = factories["moderation.Note"]()
|
||||
url = reverse("api:v1:manage:moderation:notes-list")
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0] == serializers.ManageNoteSerializer(note).data
|
||||
|
||||
|
||||
def test_note_delete(factories, superuser_api_client):
|
||||
note = factories["moderation.Note"]()
|
||||
url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid})
|
||||
response = superuser_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_note_detail(factories, superuser_api_client):
|
||||
note = factories["moderation.Note"]()
|
||||
url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid})
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == serializers.ManageNoteSerializer(note).data
|
||||
|
||||
|
||||
def test_tag_detail(factories, superuser_api_client):
|
||||
tag = factories["tags.Tag"]()
|
||||
url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name})
|
||||
|
@ -417,3 +461,37 @@ def test_tag_delete(factories, superuser_api_client):
|
|||
response = superuser_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_report_detail(factories, superuser_api_client):
|
||||
report = factories["moderation.Report"]()
|
||||
url = reverse(
|
||||
"api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid}
|
||||
)
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["summary"] == report.summary
|
||||
|
||||
|
||||
def test_report_list(factories, superuser_api_client, settings):
|
||||
report = factories["moderation.Report"]()
|
||||
url = reverse("api:v1:manage:moderation:reports-list")
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["summary"] == report.summary
|
||||
|
||||
|
||||
def test_report_update(factories, superuser_api_client):
|
||||
report = factories["moderation.Report"]()
|
||||
url = reverse(
|
||||
"api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid}
|
||||
)
|
||||
response = superuser_api_client.patch(url, {"is_handled": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
report.refresh_from_db()
|
||||
assert report.is_handled is True
|
||||
|
|
|
@ -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
|
|
@ -1,4 +1,8 @@
|
|||
import json
|
||||
import pytest
|
||||
import urllib.parse
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
|
@ -41,7 +45,6 @@ def test_user_filter_serializer_save(factories):
|
|||
("music.Album", "album", "id", serializers.AlbumStateSerializer),
|
||||
("music.Track", "track", "id", serializers.TrackStateSerializer),
|
||||
("music.Library", "library", "uuid", serializers.LibraryStateSerializer),
|
||||
("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer),
|
||||
(
|
||||
"federation.Actor",
|
||||
"account",
|
||||
|
@ -50,8 +53,8 @@ def test_user_filter_serializer_save(factories):
|
|||
),
|
||||
],
|
||||
)
|
||||
def test_report_serializer_save(
|
||||
factory_name, target_type, id_field, state_serializer, factories, mocker
|
||||
def test_report_federated_entity_serializer_save(
|
||||
factory_name, target_type, id_field, state_serializer, factories, mocker, settings
|
||||
):
|
||||
target = factories[factory_name]()
|
||||
target_owner = factories["federation.Actor"]()
|
||||
|
@ -72,10 +75,58 @@ def test_report_serializer_save(
|
|||
|
||||
report = serializer.save()
|
||||
|
||||
expected_state = state_serializer(target).data
|
||||
expected_state["_target"] = json.loads(
|
||||
json.dumps(target_data, cls=DjangoJSONEncoder)
|
||||
)
|
||||
expected_state["domain"] = urllib.parse.urlparse(target.fid).hostname
|
||||
expected_state["is_local"] = (
|
||||
expected_state["domain"] == settings.FEDERATION_HOSTNAME
|
||||
)
|
||||
|
||||
assert report.target == target
|
||||
assert report.type == payload["type"]
|
||||
assert report.summary == payload["summary"]
|
||||
assert report.target_state == state_serializer(target).data
|
||||
assert report.target_state == expected_state
|
||||
assert report.target_owner == target_owner
|
||||
get_target_owner.assert_called_once_with(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory_name, target_type, id_field, state_serializer",
|
||||
[("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer)],
|
||||
)
|
||||
def test_report_local_entity_serializer_save(
|
||||
factory_name, target_type, id_field, state_serializer, factories, mocker, settings
|
||||
):
|
||||
target = factories[factory_name]()
|
||||
target_owner = factories["federation.Actor"]()
|
||||
submitter = factories["federation.Actor"]()
|
||||
target_data = {"type": target_type, id_field: getattr(target, id_field)}
|
||||
payload = {
|
||||
"summary": "Report content",
|
||||
"type": "illegal_content",
|
||||
"target": target_data,
|
||||
}
|
||||
serializer = serializers.ReportSerializer(
|
||||
data=payload, context={"submitter": submitter}
|
||||
)
|
||||
get_target_owner = mocker.patch.object(
|
||||
serializers, "get_target_owner", return_value=target_owner
|
||||
)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
|
||||
report = serializer.save()
|
||||
|
||||
expected_state = state_serializer(target).data
|
||||
expected_state["_target"] = json.loads(
|
||||
json.dumps(target_data, cls=DjangoJSONEncoder)
|
||||
)
|
||||
expected_state["is_local"] = True
|
||||
assert report.target == target
|
||||
assert report.type == payload["type"]
|
||||
assert report.summary == payload["summary"]
|
||||
assert report.target_state == expected_state
|
||||
assert report.target_owner == target_owner
|
||||
get_target_owner.assert_called_once_with(target)
|
||||
|
||||
|
|
|
@ -50,6 +50,10 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:instance:edits",
|
||||
"read:instance:libraries",
|
||||
"write:instance:libraries",
|
||||
"read:instance:notes",
|
||||
"write:instance:notes",
|
||||
"read:instance:reports",
|
||||
"write:instance:reports",
|
||||
},
|
||||
),
|
||||
(
|
||||
|
@ -91,6 +95,10 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:instance:users",
|
||||
"read:instance:invitations",
|
||||
"write:instance:invitations",
|
||||
"read:instance:notes",
|
||||
"write:instance:notes",
|
||||
"read:instance:reports",
|
||||
"write:instance:reports",
|
||||
},
|
||||
),
|
||||
(
|
||||
|
@ -126,6 +134,10 @@ from funkwhale_api.users.oauth import scopes
|
|||
"write:instance:accounts",
|
||||
"read:instance:domains",
|
||||
"write:instance:domains",
|
||||
"read:instance:notes",
|
||||
"write:instance:notes",
|
||||
"read:instance:reports",
|
||||
"write:instance:reports",
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
|
@ -15,14 +15,15 @@
|
|||
"dateformat": "^3.0.3",
|
||||
"diff": "^4.0.1",
|
||||
"django-channels": "^1.1.6",
|
||||
"fomantic-ui-css": "^2.7",
|
||||
"howler": "^2.0.14",
|
||||
"js-logger": "^1.4.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.10",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"moment": "^2.22.2",
|
||||
"fomantic-ui-css": "^2.7",
|
||||
"qs": "^6.7.0",
|
||||
"sanitize-html": "^1.20.1",
|
||||
"showdown": "^1.8.6",
|
||||
"vue": "^2.5.17",
|
||||
"vue-gettext": "^2.1.0",
|
||||
|
|
|
@ -93,8 +93,12 @@
|
|||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['moderation']"
|
||||
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>
|
||||
<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
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,9 @@
|
|||
<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" />
|
||||
{{ actor.full_username | truncate(30) }}
|
||||
</router-link>
|
||||
<span v-else :title="actor.full_username">
|
||||
<actor-avatar v-if="avatar" :actor="actor" />
|
||||
{{ actor.full_username | truncate(30) }}
|
||||
</span>
|
||||
|
@ -11,7 +15,8 @@ import {hashCode, intToRGB} from '@/utils/color'
|
|||
export default {
|
||||
props: {
|
||||
actor: {type: Object},
|
||||
avatar: {type: Boolean, default: true}
|
||||
avatar: {type: Boolean, default: true},
|
||||
admin: {type: Boolean, default: false},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -48,4 +48,16 @@ import EmptyState from '@/components/common/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 {}
|
||||
|
|
|
@ -158,6 +158,9 @@ export default {
|
|||
},
|
||||
|
||||
updatedFields () {
|
||||
if (!this.obj.target) {
|
||||
return []
|
||||
}
|
||||
let payload = this.obj.payload
|
||||
let previousState = this.previousState
|
||||
let fields = Object.keys(payload)
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<button class="ui button" @click.prevent="show = !show">
|
||||
<i class="shield icon"></i>
|
||||
<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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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})">
|
||||
 
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{{ 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>
|
|
@ -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: {
|
||||
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||
|
|
|
@ -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>
|
|
@ -46,9 +46,11 @@ export default {
|
|||
handler (newValue) {
|
||||
if (newValue) {
|
||||
this.initModal()
|
||||
this.$emit('show')
|
||||
this.control.modal('show')
|
||||
} else {
|
||||
if (this.control) {
|
||||
this.$emit('hide')
|
||||
this.control.modal('hide')
|
||||
this.control.remove()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
export default {
|
||||
clone: require('lodash/clone'),
|
||||
keys: require('lodash/keys'),
|
||||
debounce: require('lodash/debounce'),
|
||||
get: require('lodash/get'),
|
||||
merge: require('lodash/merge'),
|
||||
|
|
|
@ -447,7 +447,30 @@ export default new Router({
|
|||
/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"
|
||||
),
|
||||
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
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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})
|
||||
}
|
|
@ -126,7 +126,12 @@ export default {
|
|||
resolve(response.data)
|
||||
})
|
||||
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('moderation/fetchContentFilters', null, { root: true })
|
||||
dispatch('playlists/fetchOwn', null, { root: true })
|
||||
|
|
|
@ -14,6 +14,7 @@ export default {
|
|||
notifications: {
|
||||
inbox: 0,
|
||||
pendingReviewEdits: 0,
|
||||
pendingReviewReports: 0,
|
||||
},
|
||||
websocketEventsHandlers: {
|
||||
'inbox.item_added': {},
|
||||
|
@ -74,6 +75,11 @@ export default {
|
|||
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) {
|
||||
let handlers = state.websocketEventsHandlers[event.type]
|
||||
console.log('Dispatching websocket event', event, handlers)
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
// @import "~fomantic-ui-css/components/ad.css";
|
||||
@import "~fomantic-ui-css/components/card.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/statistic.css";
|
||||
|
||||
|
@ -368,5 +368,20 @@ input + .help {
|
|||
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/_dark.scss";
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -174,6 +174,7 @@
|
|||
class="ui search selection dropdown">
|
||||
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
|
||||
</select>
|
||||
<action-feedback :is-loading="updating.permissions"></action-feedback>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -308,8 +309,9 @@
|
|||
name="quota"
|
||||
type="number" />
|
||||
<div class="ui basic label">
|
||||
<translate translate-context="Content/*/*/Unit">MB</translate>
|
||||
<translate translate-context="Content/*/*/Unit">MB</translate> 
|
||||
</div>
|
||||
<action-feedback class="ui basic label" size="tiny" :is-loading="updating.upload_quota"></action-feedback>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -403,6 +405,10 @@ export default {
|
|||
stats: null,
|
||||
showPolicyForm: false,
|
||||
permissions: [],
|
||||
updating: {
|
||||
permissions: false,
|
||||
upload_quota: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
@ -457,6 +463,8 @@ export default {
|
|||
if (toNull && !newValue) {
|
||||
newValue = null
|
||||
}
|
||||
let self = this
|
||||
this.updating[attr] = true
|
||||
let params = {}
|
||||
if (attr === "permissions") {
|
||||
params["permissions"] = {}
|
||||
|
@ -471,12 +479,14 @@ export default {
|
|||
logger.default.info(
|
||||
`${attr} was updated succcessfully to ${newValue}`
|
||||
)
|
||||
self.updating[attr] = false
|
||||
},
|
||||
error => {
|
||||
logger.default.error(
|
||||
`Error while setting ${attr} to ${newValue}`,
|
||||
error
|
||||
)
|
||||
self.updating[attr] = false
|
||||
}
|
||||
)
|
||||
},
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="labels.moderation">
|
||||
<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
|
||||
class="ui item"
|
||||
:to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -1547,7 +1547,7 @@ array-union@^1.0.1, array-union@^1.0.2:
|
|||
dependencies:
|
||||
array-uniq "^1.0.1"
|
||||
|
||||
array-uniq@^1.0.1:
|
||||
array-uniq@^1.0.1, array-uniq@^1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
|
||||
integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
|
||||
|
@ -4485,7 +4485,7 @@ html-webpack-plugin@^3.2.0:
|
|||
toposort "^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"
|
||||
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
|
||||
integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
|
||||
|
@ -5401,16 +5401,36 @@ locate-path@^3.0.0:
|
|||
p-locate "^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:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz#bec1024f85b1bd96cbea405b23c14ad6443a6f81"
|
||||
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:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
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:
|
||||
version "4.1.1"
|
||||
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"
|
||||
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:
|
||||
version "4.7.0"
|
||||
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"
|
||||
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:
|
||||
version "2.2.4"
|
||||
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"
|
||||
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:
|
||||
version "1.16.1"
|
||||
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"
|
||||
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:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
|
||||
|
|
Loading…
Reference in New Issue