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

moderator interface for reports (#890)

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

View File

@ -57,6 +57,64 @@ class SmartSearchFilter(django_filters.CharFilter):
return search.apply(qs, cleaned)
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)

View File

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

View File

@ -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"]

View File

@ -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

View File

@ -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")

View File

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

View File

@ -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"]

View File

@ -5,6 +5,8 @@ from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.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)

View File

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

View File

@ -1,11 +1,12 @@
import urllib.parse
import 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

View File

@ -1,5 +1,9 @@
import persisting_theory
import json
import urllib.parse
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
import persisting_theory
from rest_framework import serializers
from 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)

View File

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

View File

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

View File

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

View File

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

View File

@ -87,6 +87,7 @@ def test_manage_actor_serializer(factories, now, to_api_date):
"full_username": actor.full_username,
"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

View File

@ -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

View File

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

View File

@ -1,4 +1,8 @@
import json
import pytest
import 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)

View File

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

View File

@ -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",

View File

@ -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']"

View File

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

View File

@ -1,5 +1,9 @@
<template>
<span :title="actor.full_username">
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }}
</router-link>
<span v-else :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ 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>

View File

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

View File

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

View File

@ -48,4 +48,16 @@ import EmptyState from '@/components/common/EmptyState'
Vue.component('empty-state', EmptyState)
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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,9 +46,11 @@ export default {
handler (newValue) {
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()
}

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

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

View File

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

View File

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

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

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

View File

@ -126,7 +126,12 @@ export default {
resolve(response.data)
})
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 })

View File

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

View File

@ -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";

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

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

View File

@ -174,6 +174,7 @@
class="ui search selection dropdown">
<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>&#32;
</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
}
)
},

View File

@ -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>

View File

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

View File

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

View File

@ -1547,7 +1547,7 @@ array-union@^1.0.1, array-union@^1.0.2:
dependencies:
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"