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) |         return search.apply(qs, cleaned) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def get_generic_filter_query(value, relation_name, choices): | ||||||
|  |     parts = value.split(":", 1) | ||||||
|  |     type = parts[0] | ||||||
|  |     try: | ||||||
|  |         conf = choices[type] | ||||||
|  |     except KeyError: | ||||||
|  |         raise forms.ValidationError("Invalid type") | ||||||
|  |     related_queryset = conf["queryset"] | ||||||
|  |     related_model = related_queryset.model | ||||||
|  |     filter_query = models.Q( | ||||||
|  |         **{ | ||||||
|  |             "{}_content_type__app_label".format( | ||||||
|  |                 relation_name | ||||||
|  |             ): related_model._meta.app_label, | ||||||
|  |             "{}_content_type__model".format( | ||||||
|  |                 relation_name | ||||||
|  |             ): related_model._meta.model_name, | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     if len(parts) > 1: | ||||||
|  |         id_attr = conf.get("id_attr", "id") | ||||||
|  |         id_field = conf.get("id_field", serializers.IntegerField(min_value=1)) | ||||||
|  |         try: | ||||||
|  |             id_value = parts[1] | ||||||
|  |             id_value = id_field.to_internal_value(id_value) | ||||||
|  |         except (TypeError, KeyError, serializers.ValidationError): | ||||||
|  |             raise forms.ValidationError("Invalid id") | ||||||
|  |         query_getter = conf.get( | ||||||
|  |             "get_query", lambda attr, value: models.Q(**{attr: value}) | ||||||
|  |         ) | ||||||
|  |         obj_query = query_getter(id_attr, id_value) | ||||||
|  |         try: | ||||||
|  |             obj = related_queryset.get(obj_query) | ||||||
|  |         except related_queryset.model.DoesNotExist: | ||||||
|  |             raise forms.ValidationError("Invalid object") | ||||||
|  |         filter_query &= models.Q(**{"{}_id".format(relation_name): obj.id}) | ||||||
|  | 
 | ||||||
|  |     return filter_query | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class GenericRelationFilter(django_filters.CharFilter): | ||||||
|  |     def __init__(self, relation_name, choices, *args, **kwargs): | ||||||
|  |         self.relation_name = relation_name | ||||||
|  |         self.choices = choices | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  | 
 | ||||||
|  |     def filter(self, qs, value): | ||||||
|  |         if not value: | ||||||
|  |             return qs | ||||||
|  |         try: | ||||||
|  |             filter_query = get_generic_filter_query( | ||||||
|  |                 value, relation_name=self.relation_name, choices=self.choices | ||||||
|  |             ) | ||||||
|  |         except forms.ValidationError: | ||||||
|  |             return qs.none() | ||||||
|  |         return qs.filter(filter_query) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class GenericRelation(serializers.JSONField): | class GenericRelation(serializers.JSONField): | ||||||
|     def __init__(self, choices, *args, **kwargs): |     def __init__(self, choices, *args, **kwargs): | ||||||
|         self.choices = choices |         self.choices = choices | ||||||
|  | @ -68,14 +126,16 @@ class GenericRelation(serializers.JSONField): | ||||||
|             return |             return | ||||||
|         type = None |         type = None | ||||||
|         id = None |         id = None | ||||||
|  |         id_attr = None | ||||||
|         for key, choice in self.choices.items(): |         for key, choice in self.choices.items(): | ||||||
|             if isinstance(value, choice["queryset"].model): |             if isinstance(value, choice["queryset"].model): | ||||||
|                 type = key |                 type = key | ||||||
|                 id = getattr(value, choice.get("id_attr", "id")) |                 id_attr = choice.get("id_attr", "id") | ||||||
|  |                 id = getattr(value, id_attr) | ||||||
|                 break |                 break | ||||||
| 
 | 
 | ||||||
|         if type: |         if type: | ||||||
|             return {"type": type, "id": id} |             return {"type": type, id_attr: id} | ||||||
| 
 | 
 | ||||||
|     def to_internal_value(self, v): |     def to_internal_value(self, v): | ||||||
|         v = super().to_internal_value(v) |         v = super().to_internal_value(v) | ||||||
|  |  | ||||||
|  | @ -15,7 +15,7 @@ class NoneObject(object): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| NONE = NoneObject() | NONE = NoneObject() | ||||||
| NULL_BOOLEAN_CHOICES = [ | BOOLEAN_CHOICES = [ | ||||||
|     (True, True), |     (True, True), | ||||||
|     ("true", True), |     ("true", True), | ||||||
|     ("True", True), |     ("True", True), | ||||||
|  | @ -26,6 +26,8 @@ NULL_BOOLEAN_CHOICES = [ | ||||||
|     ("False", False), |     ("False", False), | ||||||
|     ("0", False), |     ("0", False), | ||||||
|     ("no", False), |     ("no", False), | ||||||
|  | ] | ||||||
|  | NULL_BOOLEAN_CHOICES = BOOLEAN_CHOICES + [ | ||||||
|     ("None", NONE), |     ("None", NONE), | ||||||
|     ("none", NONE), |     ("none", NONE), | ||||||
|     ("Null", NONE), |     ("Null", NONE), | ||||||
|  | @ -76,10 +78,26 @@ def clean_null_boolean_filter(v): | ||||||
|     return v |     return v | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def clean_boolean_filter(v): | ||||||
|  |     return CoerceChoiceField(choices=BOOLEAN_CHOICES).clean(v) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def get_null_boolean_filter(name): | def get_null_boolean_filter(name): | ||||||
|     return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} |     return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def get_boolean_filter(name): | ||||||
|  |     return {"handler": lambda v: Q(**{name: clean_boolean_filter(v)})} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def get_generic_relation_filter(relation_name, choices): | ||||||
|  |     return { | ||||||
|  |         "handler": lambda v: fields.get_generic_filter_query( | ||||||
|  |             v, relation_name=relation_name, choices=choices | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): | class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): | ||||||
|     def valid_value(self, value): |     def valid_value(self, value): | ||||||
|         return True |         return True | ||||||
|  | @ -142,7 +160,7 @@ class MutationFilter(filters.FilterSet): | ||||||
|                 "domain": {"to": "created_by__domain__name__iexact"}, |                 "domain": {"to": "created_by__domain__name__iexact"}, | ||||||
|                 "is_approved": get_null_boolean_filter("is_approved"), |                 "is_approved": get_null_boolean_filter("is_approved"), | ||||||
|                 "target": {"handler": filter_target}, |                 "target": {"handler": filter_target}, | ||||||
|                 "is_applied": {"to": "is_applied"}, |                 "is_applied": get_boolean_filter("is_applied"), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  | @ -5,11 +5,14 @@ import django_filters | ||||||
| from django_filters import rest_framework as filters | from django_filters import rest_framework as filters | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import fields | from funkwhale_api.common import fields | ||||||
|  | from funkwhale_api.common import filters as common_filters | ||||||
| from funkwhale_api.common import search | from funkwhale_api.common import search | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.federation import models as federation_models | from funkwhale_api.federation import models as federation_models | ||||||
| from funkwhale_api.federation import utils as federation_utils | from funkwhale_api.federation import utils as federation_utils | ||||||
| from funkwhale_api.moderation import models as moderation_models | from funkwhale_api.moderation import models as moderation_models | ||||||
|  | from funkwhale_api.moderation import serializers as moderation_serializers | ||||||
|  | from funkwhale_api.moderation import utils as moderation_utils | ||||||
| from funkwhale_api.music import models as music_models | from funkwhale_api.music import models as music_models | ||||||
| from funkwhale_api.users import models as users_models | from funkwhale_api.users import models as users_models | ||||||
| from funkwhale_api.tags import models as tags_models | from funkwhale_api.tags import models as tags_models | ||||||
|  | @ -26,7 +29,7 @@ class ActorField(forms.CharField): | ||||||
| 
 | 
 | ||||||
| def get_actor_filter(actor_field): | def get_actor_filter(actor_field): | ||||||
|     def handler(v): |     def handler(v): | ||||||
|         federation_utils.get_actor_from_username_data_query(actor_field, v) |         return federation_utils.get_actor_from_username_data_query(actor_field, v) | ||||||
| 
 | 
 | ||||||
|     return {"field": ActorField(), "handler": handler} |     return {"field": ActorField(), "handler": handler} | ||||||
| 
 | 
 | ||||||
|  | @ -322,6 +325,10 @@ class ManageInstancePolicyFilterSet(filters.FilterSet): | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     target_domain = filters.CharFilter("target_domain__name") | ||||||
|  |     target_account_domain = filters.CharFilter("target_actor__domain__name") | ||||||
|  |     target_account_username = filters.CharFilter("target_actor__preferred_username") | ||||||
|  | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = moderation_models.InstancePolicy |         model = moderation_models.InstancePolicy | ||||||
|         fields = [ |         fields = [ | ||||||
|  | @ -330,6 +337,9 @@ class ManageInstancePolicyFilterSet(filters.FilterSet): | ||||||
|             "silence_activity", |             "silence_activity", | ||||||
|             "silence_notifications", |             "silence_notifications", | ||||||
|             "reject_media", |             "reject_media", | ||||||
|  |             "target_domain", | ||||||
|  |             "target_account_domain", | ||||||
|  |             "target_account_username", | ||||||
|         ] |         ] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -339,3 +349,48 @@ class ManageTagFilterSet(filters.FilterSet): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = tags_models.Tag |         model = tags_models.Tag | ||||||
|         fields = ["q"] |         fields = ["q"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageReportFilterSet(filters.FilterSet): | ||||||
|  |     q = fields.SmartSearchFilter( | ||||||
|  |         config=search.SearchConfig( | ||||||
|  |             search_fields={"summary": {"to": "summary"}}, | ||||||
|  |             filter_fields={ | ||||||
|  |                 "uuid": {"to": "uuid"}, | ||||||
|  |                 "id": {"to": "id"}, | ||||||
|  |                 "resolved": common_filters.get_boolean_filter("is_handled"), | ||||||
|  |                 "domain": {"to": "target_owner__domain_id"}, | ||||||
|  |                 "category": {"to": "type"}, | ||||||
|  |                 "submitter": get_actor_filter("submitter"), | ||||||
|  |                 "assigned_to": get_actor_filter("assigned_to"), | ||||||
|  |                 "target_owner": get_actor_filter("target_owner"), | ||||||
|  |                 "submitter_email": {"to": "submitter_email"}, | ||||||
|  |                 "target": common_filters.get_generic_relation_filter( | ||||||
|  |                     "target", moderation_serializers.TARGET_CONFIG | ||||||
|  |                 ), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = moderation_models.Report | ||||||
|  |         fields = ["q", "is_handled", "type", "submitter_email"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageNoteFilterSet(filters.FilterSet): | ||||||
|  |     q = fields.SmartSearchFilter( | ||||||
|  |         config=search.SearchConfig( | ||||||
|  |             search_fields={"summary": {"to": "summary"}}, | ||||||
|  |             filter_fields={ | ||||||
|  |                 "uuid": {"to": "uuid"}, | ||||||
|  |                 "author": get_actor_filter("author"), | ||||||
|  |                 "target": common_filters.get_generic_relation_filter( | ||||||
|  |                     "target", moderation_utils.NOTE_TARGET_FIELDS | ||||||
|  |                 ), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = moderation_models.Note | ||||||
|  |         fields = ["q"] | ||||||
|  |  | ||||||
|  | @ -1,13 +1,17 @@ | ||||||
|  | from django.conf import settings | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| 
 | 
 | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| 
 | 
 | ||||||
|  | from funkwhale_api.common import fields as common_fields | ||||||
| from funkwhale_api.common import serializers as common_serializers | from funkwhale_api.common import serializers as common_serializers | ||||||
| from funkwhale_api.common import utils as common_utils | from funkwhale_api.common import utils as common_utils | ||||||
| from funkwhale_api.federation import models as federation_models | from funkwhale_api.federation import models as federation_models | ||||||
| from funkwhale_api.federation import fields as federation_fields | from funkwhale_api.federation import fields as federation_fields | ||||||
| from funkwhale_api.federation import tasks as federation_tasks | from funkwhale_api.federation import tasks as federation_tasks | ||||||
| from funkwhale_api.moderation import models as moderation_models | from funkwhale_api.moderation import models as moderation_models | ||||||
|  | from funkwhale_api.moderation import serializers as moderation_serializers | ||||||
|  | from funkwhale_api.moderation import utils as moderation_utils | ||||||
| from funkwhale_api.music import models as music_models | from funkwhale_api.music import models as music_models | ||||||
| from funkwhale_api.music import serializers as music_serializers | from funkwhale_api.music import serializers as music_serializers | ||||||
| from funkwhale_api.tags import models as tags_models | from funkwhale_api.tags import models as tags_models | ||||||
|  | @ -182,6 +186,8 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ManageBaseActorSerializer(serializers.ModelSerializer): | class ManageBaseActorSerializer(serializers.ModelSerializer): | ||||||
|  |     is_local = serializers.SerializerMethodField() | ||||||
|  | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = federation_models.Actor |         model = federation_models.Actor | ||||||
|         fields = [ |         fields = [ | ||||||
|  | @ -200,9 +206,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer): | ||||||
|             "outbox_url", |             "outbox_url", | ||||||
|             "shared_inbox_url", |             "shared_inbox_url", | ||||||
|             "manually_approves_followers", |             "manually_approves_followers", | ||||||
|  |             "is_local", | ||||||
|         ] |         ] | ||||||
|         read_only_fields = ["creation_date", "instance_policy"] |         read_only_fields = ["creation_date", "instance_policy"] | ||||||
| 
 | 
 | ||||||
|  |     def get_is_local(self, o): | ||||||
|  |         return o.domain_id == settings.FEDERATION_HOSTNAME | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class ManageActorSerializer(ManageBaseActorSerializer): | class ManageActorSerializer(ManageBaseActorSerializer): | ||||||
|     uploads_count = serializers.SerializerMethodField() |     uploads_count = serializers.SerializerMethodField() | ||||||
|  | @ -629,3 +639,64 @@ class ManageTagActionSerializer(common_serializers.ActionSerializer): | ||||||
|     @transaction.atomic |     @transaction.atomic | ||||||
|     def handle_delete(self, objects): |     def handle_delete(self, objects): | ||||||
|         return objects.delete() |         return objects.delete() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageBaseNoteSerializer(serializers.ModelSerializer): | ||||||
|  |     author = ManageBaseActorSerializer(required=False, read_only=True) | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = moderation_models.Note | ||||||
|  |         fields = ["id", "uuid", "creation_date", "summary", "author"] | ||||||
|  |         read_only_fields = ["uuid", "creation_date", "author"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageNoteSerializer(ManageBaseNoteSerializer): | ||||||
|  |     target = common_fields.GenericRelation(moderation_utils.NOTE_TARGET_FIELDS) | ||||||
|  | 
 | ||||||
|  |     class Meta(ManageBaseNoteSerializer.Meta): | ||||||
|  |         fields = ManageBaseNoteSerializer.Meta.fields + ["target"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageReportSerializer(serializers.ModelSerializer): | ||||||
|  |     assigned_to = ManageBaseActorSerializer() | ||||||
|  |     target_owner = ManageBaseActorSerializer() | ||||||
|  |     submitter = ManageBaseActorSerializer() | ||||||
|  |     target = moderation_serializers.TARGET_FIELD | ||||||
|  |     notes = serializers.SerializerMethodField() | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = moderation_models.Report | ||||||
|  |         fields = [ | ||||||
|  |             "id", | ||||||
|  |             "uuid", | ||||||
|  |             "fid", | ||||||
|  |             "creation_date", | ||||||
|  |             "handled_date", | ||||||
|  |             "summary", | ||||||
|  |             "type", | ||||||
|  |             "target", | ||||||
|  |             "target_state", | ||||||
|  |             "is_handled", | ||||||
|  |             "assigned_to", | ||||||
|  |             "target_owner", | ||||||
|  |             "submitter", | ||||||
|  |             "submitter_email", | ||||||
|  |             "notes", | ||||||
|  |         ] | ||||||
|  |         read_only_fields = [ | ||||||
|  |             "id", | ||||||
|  |             "uuid", | ||||||
|  |             "fid", | ||||||
|  |             "submitter", | ||||||
|  |             "submitter_email", | ||||||
|  |             "creation_date", | ||||||
|  |             "handled_date", | ||||||
|  |             "target", | ||||||
|  |             "target_state", | ||||||
|  |             "target_owner", | ||||||
|  |             "summary", | ||||||
|  |         ] | ||||||
|  | 
 | ||||||
|  |     def get_notes(self, o): | ||||||
|  |         notes = getattr(o, "_prefetched_notes", []) | ||||||
|  |         return ManageBaseNoteSerializer(notes, many=True).data | ||||||
|  |  | ||||||
|  | @ -17,6 +17,8 @@ moderation_router = routers.OptionalSlashRouter() | ||||||
| moderation_router.register( | moderation_router.register( | ||||||
|     r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" |     r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies" | ||||||
| ) | ) | ||||||
|  | moderation_router.register(r"reports", views.ManageReportViewSet, "reports") | ||||||
|  | moderation_router.register(r"notes", views.ManageNoteViewSet, "notes") | ||||||
| 
 | 
 | ||||||
| users_router = routers.OptionalSlashRouter() | users_router = routers.OptionalSlashRouter() | ||||||
| users_router.register(r"users", views.ManageUserViewSet, "users") | users_router.register(r"users", views.ManageUserViewSet, "users") | ||||||
|  |  | ||||||
|  | @ -459,6 +459,60 @@ class ManageInstancePolicyViewSet( | ||||||
|         serializer.save(actor=self.request.user.actor) |         serializer.save(actor=self.request.user.actor) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class ManageReportViewSet( | ||||||
|  |     mixins.ListModelMixin, | ||||||
|  |     mixins.RetrieveModelMixin, | ||||||
|  |     mixins.UpdateModelMixin, | ||||||
|  |     viewsets.GenericViewSet, | ||||||
|  | ): | ||||||
|  |     lookup_field = "uuid" | ||||||
|  |     queryset = ( | ||||||
|  |         moderation_models.Report.objects.all() | ||||||
|  |         .order_by("-creation_date") | ||||||
|  |         .select_related( | ||||||
|  |             "submitter", "target_owner", "assigned_to", "target_content_type" | ||||||
|  |         ) | ||||||
|  |         .prefetch_related("target") | ||||||
|  |         .prefetch_related( | ||||||
|  |             Prefetch( | ||||||
|  |                 "notes", | ||||||
|  |                 queryset=moderation_models.Note.objects.order_by( | ||||||
|  |                     "creation_date" | ||||||
|  |                 ).select_related("author"), | ||||||
|  |                 to_attr="_prefetched_notes", | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  |     serializer_class = serializers.ManageReportSerializer | ||||||
|  |     filterset_class = filters.ManageReportFilterSet | ||||||
|  |     required_scope = "instance:reports" | ||||||
|  |     ordering_fields = ["id", "creation_date", "handled_date"] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ManageNoteViewSet( | ||||||
|  |     mixins.ListModelMixin, | ||||||
|  |     mixins.RetrieveModelMixin, | ||||||
|  |     mixins.DestroyModelMixin, | ||||||
|  |     mixins.CreateModelMixin, | ||||||
|  |     viewsets.GenericViewSet, | ||||||
|  | ): | ||||||
|  |     lookup_field = "uuid" | ||||||
|  |     queryset = ( | ||||||
|  |         moderation_models.Note.objects.all() | ||||||
|  |         .order_by("-creation_date") | ||||||
|  |         .select_related("author", "target_content_type") | ||||||
|  |         .prefetch_related("target") | ||||||
|  |     ) | ||||||
|  |     serializer_class = serializers.ManageNoteSerializer | ||||||
|  |     filterset_class = filters.ManageNoteFilterSet | ||||||
|  |     required_scope = "instance:notes" | ||||||
|  |     ordering_fields = ["id", "creation_date"] | ||||||
|  | 
 | ||||||
|  |     def perform_create(self, serializer): | ||||||
|  |         author = self.request.user.actor | ||||||
|  |         return serializer.save(author=author) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class ManageTagViewSet( | class ManageTagViewSet( | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|  |  | ||||||
|  | @ -30,6 +30,22 @@ class InstancePolicyAdmin(admin.ModelAdmin): | ||||||
|     list_select_related = True |     list_select_related = True | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @admin.register(models.Report) | ||||||
|  | class ReportAdmin(admin.ModelAdmin): | ||||||
|  |     list_display = [ | ||||||
|  |         "uuid", | ||||||
|  |         "submitter", | ||||||
|  |         "type", | ||||||
|  |         "assigned_to", | ||||||
|  |         "is_handled", | ||||||
|  |         "creation_date", | ||||||
|  |         "handled_date", | ||||||
|  |     ] | ||||||
|  |     list_filter = ["type", "is_handled"] | ||||||
|  |     search_fields = ["summary"] | ||||||
|  |     list_select_related = True | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @admin.register(models.UserFilter) | @admin.register(models.UserFilter) | ||||||
| class UserFilterAdmin(admin.ModelAdmin): | class UserFilterAdmin(admin.ModelAdmin): | ||||||
|     list_display = ["uuid", "user", "target_artist", "creation_date"] |     list_display = ["uuid", "user", "target_artist", "creation_date"] | ||||||
|  |  | ||||||
|  | @ -5,6 +5,8 @@ from funkwhale_api.federation import factories as federation_factories | ||||||
| from funkwhale_api.music import factories as music_factories | from funkwhale_api.music import factories as music_factories | ||||||
| from funkwhale_api.users import factories as users_factories | from funkwhale_api.users import factories as users_factories | ||||||
| 
 | 
 | ||||||
|  | from . import serializers | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @registry.register | @registry.register | ||||||
| class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | ||||||
|  | @ -39,10 +41,20 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @registry.register | ||||||
|  | class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | ||||||
|  |     author = factory.SubFactory(federation_factories.ActorFactory) | ||||||
|  |     target = None | ||||||
|  |     summary = factory.Faker("paragraph") | ||||||
|  | 
 | ||||||
|  |     class Meta: | ||||||
|  |         model = "moderation.Note" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @registry.register | @registry.register | ||||||
| class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | ||||||
|     submitter = factory.SubFactory(federation_factories.ActorFactory) |     submitter = factory.SubFactory(federation_factories.ActorFactory) | ||||||
|     target = None |     target = factory.SubFactory(music_factories.ArtistFactory) | ||||||
|     summary = factory.Faker("paragraph") |     summary = factory.Faker("paragraph") | ||||||
|     type = "other" |     type = "other" | ||||||
| 
 | 
 | ||||||
|  | @ -51,3 +63,13 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): | ||||||
| 
 | 
 | ||||||
|     class Params: |     class Params: | ||||||
|         anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) |         anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email")) | ||||||
|  |         assigned = factory.Trait( | ||||||
|  |             assigned_to=factory.SubFactory(federation_factories.ActorFactory) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     @factory.post_generation | ||||||
|  |     def _set_target_owner(self, create, extracted, **kwargs): | ||||||
|  |         if not self.target: | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         self.target_owner = serializers.get_target_owner(self.target) | ||||||
|  |  | ||||||
|  | @ -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 urllib.parse | ||||||
| import uuid | import uuid | ||||||
| 
 | 
 | ||||||
| 
 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation | ||||||
| from django.contrib.contenttypes.fields import GenericForeignKey |  | ||||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||||
| from django.contrib.postgres.fields import JSONField | from django.contrib.postgres.fields import JSONField | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.db.models.signals import pre_save | ||||||
|  | from django.dispatch import receiver | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
| 
 | 
 | ||||||
|  | @ -147,6 +148,10 @@ class Report(federation_models.FederationMixin): | ||||||
|     # delete |     # delete | ||||||
|     target_state = JSONField(null=True) |     target_state = JSONField(null=True) | ||||||
| 
 | 
 | ||||||
|  |     notes = GenericRelation( | ||||||
|  |         "Note", content_type_field="target_content_type", object_id_field="target_id" | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|     def get_federation_id(self): |     def get_federation_id(self): | ||||||
|         if self.fid: |         if self.fid: | ||||||
|             return self.fid |             return self.fid | ||||||
|  | @ -160,3 +165,26 @@ class Report(federation_models.FederationMixin): | ||||||
|             self.fid = self.get_federation_id() |             self.fid = self.get_federation_id() | ||||||
| 
 | 
 | ||||||
|         return super().save(**kwargs) |         return super().save(**kwargs) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Note(models.Model): | ||||||
|  |     uuid = models.UUIDField(default=uuid.uuid4, unique=True) | ||||||
|  |     creation_date = models.DateTimeField(default=timezone.now) | ||||||
|  |     summary = models.TextField(max_length=50000) | ||||||
|  |     author = models.ForeignKey( | ||||||
|  |         "federation.Actor", related_name="moderation_notes", on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     target_id = models.IntegerField(null=True) | ||||||
|  |     target_content_type = models.ForeignKey( | ||||||
|  |         ContentType, null=True, on_delete=models.CASCADE | ||||||
|  |     ) | ||||||
|  |     target = GenericForeignKey("target_content_type", "target_id") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @receiver(pre_save, sender=Report) | ||||||
|  | def set_handled_date(sender, instance, **kwargs): | ||||||
|  |     if instance.is_handled is True and not instance.handled_date: | ||||||
|  |         instance.handled_date = timezone.now() | ||||||
|  |     elif not instance.is_handled: | ||||||
|  |         instance.handled_date = None | ||||||
|  |  | ||||||
|  | @ -1,5 +1,9 @@ | ||||||
| import persisting_theory | import json | ||||||
|  | import urllib.parse | ||||||
| 
 | 
 | ||||||
|  | from django.conf import settings | ||||||
|  | from django.core.serializers.json import DjangoJSONEncoder | ||||||
|  | import persisting_theory | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import fields as common_fields | from funkwhale_api.common import fields as common_fields | ||||||
|  | @ -117,7 +121,15 @@ class TrackStateSerializer(serializers.ModelSerializer): | ||||||
| class LibraryStateSerializer(serializers.ModelSerializer): | class LibraryStateSerializer(serializers.ModelSerializer): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = music_models.Library |         model = music_models.Library | ||||||
|         fields = ["id", "fid", "name", "description", "creation_date", "privacy_level"] |         fields = [ | ||||||
|  |             "id", | ||||||
|  |             "uuid", | ||||||
|  |             "fid", | ||||||
|  |             "name", | ||||||
|  |             "description", | ||||||
|  |             "creation_date", | ||||||
|  |             "privacy_level", | ||||||
|  |         ] | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @state_serializers.register(name="playlists.Playlist") | @state_serializers.register(name="playlists.Playlist") | ||||||
|  | @ -135,6 +147,7 @@ class ActorStateSerializer(serializers.ModelSerializer): | ||||||
|             "fid", |             "fid", | ||||||
|             "name", |             "name", | ||||||
|             "preferred_username", |             "preferred_username", | ||||||
|  |             "full_username", | ||||||
|             "summary", |             "summary", | ||||||
|             "domain", |             "domain", | ||||||
|             "type", |             "type", | ||||||
|  | @ -160,9 +173,7 @@ def get_target_owner(target): | ||||||
|     return mapping[target.__class__](target) |     return mapping[target.__class__](target) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ReportSerializer(serializers.ModelSerializer): | TARGET_CONFIG = { | ||||||
|     target = common_fields.GenericRelation( |  | ||||||
|         { |  | ||||||
|     "artist": {"queryset": music_models.Artist.objects.all()}, |     "artist": {"queryset": music_models.Artist.objects.all()}, | ||||||
|     "album": {"queryset": music_models.Album.objects.all()}, |     "album": {"queryset": music_models.Album.objects.all()}, | ||||||
|     "track": {"queryset": music_models.Track.objects.all()}, |     "track": {"queryset": music_models.Track.objects.all()}, | ||||||
|  | @ -179,7 +190,11 @@ class ReportSerializer(serializers.ModelSerializer): | ||||||
|         "get_query": get_actor_query, |         "get_query": get_actor_query, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
|     ) | TARGET_FIELD = common_fields.GenericRelation(TARGET_CONFIG) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReportSerializer(serializers.ModelSerializer): | ||||||
|  |     target = TARGET_FIELD | ||||||
| 
 | 
 | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = models.Report |         model = models.Report | ||||||
|  | @ -225,5 +240,21 @@ class ReportSerializer(serializers.ModelSerializer): | ||||||
|         validated_data["target_state"] = target_state_serializer( |         validated_data["target_state"] = target_state_serializer( | ||||||
|             validated_data["target"] |             validated_data["target"] | ||||||
|         ).data |         ).data | ||||||
|  |         # freeze target type/id in JSON so even if the corresponding object is deleted | ||||||
|  |         # we can have the info and display it in the frontend | ||||||
|  |         target_data = self.fields["target"].to_representation(validated_data["target"]) | ||||||
|  |         validated_data["target_state"]["_target"] = json.loads( | ||||||
|  |             json.dumps(target_data, cls=DjangoJSONEncoder) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         if "fid" in validated_data["target_state"]: | ||||||
|  |             validated_data["target_state"]["domain"] = urllib.parse.urlparse( | ||||||
|  |                 validated_data["target_state"]["fid"] | ||||||
|  |             ).hostname | ||||||
|  | 
 | ||||||
|  |         validated_data["target_state"]["is_local"] = ( | ||||||
|  |             validated_data["target_state"].get("domain", settings.FEDERATION_HOSTNAME) | ||||||
|  |             == settings.FEDERATION_HOSTNAME | ||||||
|  |         ) | ||||||
|         validated_data["target_owner"] = get_target_owner(validated_data["target"]) |         validated_data["target_owner"] = get_target_owner(validated_data["target"]) | ||||||
|         return super().create(validated_data) |         return super().create(validated_data) | ||||||
|  |  | ||||||
|  | @ -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", |             "write:instance:accounts", | ||||||
|             "read:instance:domains", |             "read:instance:domains", | ||||||
|             "write:instance:domains", |             "write:instance:domains", | ||||||
|  |             "read:instance:reports", | ||||||
|  |             "write:instance:reports", | ||||||
|  |             "read:instance:notes", | ||||||
|  |             "write:instance:notes", | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
|     "library": { |     "library": { | ||||||
|  |  | ||||||
|  | @ -34,6 +34,8 @@ BASE_SCOPES = [ | ||||||
|     Scope("instance:accounts", "Access instance federated accounts"), |     Scope("instance:accounts", "Access instance federated accounts"), | ||||||
|     Scope("instance:domains", "Access instance domains"), |     Scope("instance:domains", "Access instance domains"), | ||||||
|     Scope("instance:policies", "Access instance moderation policies"), |     Scope("instance:policies", "Access instance moderation policies"), | ||||||
|  |     Scope("instance:reports", "Access instance moderation reports"), | ||||||
|  |     Scope("instance:notes", "Access instance moderation notes"), | ||||||
| ] | ] | ||||||
| SCOPES = [ | SCOPES = [ | ||||||
|     Scope("read", children=[s.copy("read") for s in BASE_SCOPES]), |     Scope("read", children=[s.copy("read") for s in BASE_SCOPES]), | ||||||
|  |  | ||||||
|  | @ -67,3 +67,39 @@ def test_generic_relation_field_validation_error(payload, expected_error, factor | ||||||
| 
 | 
 | ||||||
|     with pytest.raises(fields.serializers.ValidationError, match=expected_error): |     with pytest.raises(fields.serializers.ValidationError, match=expected_error): | ||||||
|         f.to_internal_value(payload) |         f.to_internal_value(payload) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_generic_relation_filter_target_type(factories): | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     note = factories["moderation.Note"](target=user) | ||||||
|  |     factories["moderation.Note"](target=factories["music.Artist"]()) | ||||||
|  |     f = fields.GenericRelationFilter( | ||||||
|  |         "target", | ||||||
|  |         { | ||||||
|  |             "user": { | ||||||
|  |                 "queryset": user.__class__.objects.all(), | ||||||
|  |                 "id_attr": "username", | ||||||
|  |                 "id_field": fields.serializers.CharField(), | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     qs = f.filter(note.__class__.objects.all(), "user") | ||||||
|  |     assert list(qs) == [note] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_generic_relation_filter_target_type_and_id(factories): | ||||||
|  |     user = factories["users.User"]() | ||||||
|  |     note = factories["moderation.Note"](target=user) | ||||||
|  |     factories["moderation.Note"](target=factories["users.User"]()) | ||||||
|  |     f = fields.GenericRelationFilter( | ||||||
|  |         "target", | ||||||
|  |         { | ||||||
|  |             "user": { | ||||||
|  |                 "queryset": user.__class__.objects.all(), | ||||||
|  |                 "id_attr": "username", | ||||||
|  |                 "id_field": fields.serializers.CharField(), | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     qs = f.filter(note.__class__.objects.all(), "user:{}".format(user.username)) | ||||||
|  |     assert list(qs) == [note] | ||||||
|  |  | ||||||
|  | @ -87,6 +87,7 @@ def test_manage_actor_serializer(factories, now, to_api_date): | ||||||
|         "full_username": actor.full_username, |         "full_username": actor.full_username, | ||||||
|         "user": None, |         "user": None, | ||||||
|         "instance_policy": None, |         "instance_policy": None, | ||||||
|  |         "is_local": False, | ||||||
|     } |     } | ||||||
|     s = serializers.ManageActorSerializer(actor) |     s = serializers.ManageActorSerializer(actor) | ||||||
| 
 | 
 | ||||||
|  | @ -521,3 +522,49 @@ def test_manage_tag_serializer(factories, to_api_date): | ||||||
|     s = serializers.ManageTagSerializer(tag) |     s = serializers.ManageTagSerializer(tag) | ||||||
| 
 | 
 | ||||||
|     assert s.data == expected |     assert s.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_manage_report_serializer(factories, to_api_date): | ||||||
|  |     artist = factories["music.Artist"](attributed=True) | ||||||
|  |     report = factories["moderation.Report"]( | ||||||
|  |         target=artist, target_state={"hello": "world"}, assigned=True | ||||||
|  |     ) | ||||||
|  |     expected = { | ||||||
|  |         "id": report.id, | ||||||
|  |         "uuid": str(report.uuid), | ||||||
|  |         "fid": report.fid, | ||||||
|  |         "creation_date": to_api_date(report.creation_date), | ||||||
|  |         "handled_date": None, | ||||||
|  |         "summary": report.summary, | ||||||
|  |         "is_handled": report.is_handled, | ||||||
|  |         "type": report.type, | ||||||
|  |         "submitter_email": None, | ||||||
|  |         "submitter": serializers.ManageBaseActorSerializer(report.submitter).data, | ||||||
|  |         "assigned_to": serializers.ManageBaseActorSerializer(report.assigned_to).data, | ||||||
|  |         "target": {"type": "artist", "id": artist.pk}, | ||||||
|  |         "target_owner": serializers.ManageBaseActorSerializer( | ||||||
|  |             artist.attributed_to | ||||||
|  |         ).data, | ||||||
|  |         "target_state": report.target_state, | ||||||
|  |         "notes": [], | ||||||
|  |     } | ||||||
|  |     s = serializers.ManageReportSerializer(report) | ||||||
|  | 
 | ||||||
|  |     assert s.data == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_manage_note_serializer(factories, to_api_date): | ||||||
|  |     actor = factories["federation.Actor"]() | ||||||
|  |     note = factories["moderation.Note"](target=actor) | ||||||
|  | 
 | ||||||
|  |     expected = { | ||||||
|  |         "id": note.id, | ||||||
|  |         "uuid": str(note.uuid), | ||||||
|  |         "summary": note.summary, | ||||||
|  |         "creation_date": to_api_date(note.creation_date), | ||||||
|  |         "author": serializers.ManageBaseActorSerializer(note.author).data, | ||||||
|  |         "target": {"type": "account", "full_username": actor.full_username}, | ||||||
|  |     } | ||||||
|  |     s = serializers.ManageNoteSerializer(note) | ||||||
|  | 
 | ||||||
|  |     assert s.data == expected | ||||||
|  |  | ||||||
|  | @ -391,6 +391,50 @@ def test_upload_delete(factories, superuser_api_client): | ||||||
|     assert response.status_code == 204 |     assert response.status_code == 204 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | def test_note_create(factories, superuser_api_client): | ||||||
|  |     actor = superuser_api_client.user.create_actor() | ||||||
|  |     target = factories["federation.Actor"]() | ||||||
|  |     data = { | ||||||
|  |         "summary": "Hello", | ||||||
|  |         "target": {"type": "account", "full_username": target.full_username}, | ||||||
|  |     } | ||||||
|  |     url = reverse("api:v1:manage:moderation:notes-list") | ||||||
|  |     response = superuser_api_client.post(url, data, format="json") | ||||||
|  |     assert response.status_code == 201 | ||||||
|  | 
 | ||||||
|  |     note = actor.moderation_notes.latest("id") | ||||||
|  |     assert note.target == target | ||||||
|  |     assert response.data == serializers.ManageNoteSerializer(note).data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_note_list(factories, superuser_api_client, settings): | ||||||
|  |     note = factories["moderation.Note"]() | ||||||
|  |     url = reverse("api:v1:manage:moderation:notes-list") | ||||||
|  |     response = superuser_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  | 
 | ||||||
|  |     assert response.data["count"] == 1 | ||||||
|  |     assert response.data["results"][0] == serializers.ManageNoteSerializer(note).data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_note_delete(factories, superuser_api_client): | ||||||
|  |     note = factories["moderation.Note"]() | ||||||
|  |     url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid}) | ||||||
|  |     response = superuser_api_client.delete(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 204 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_note_detail(factories, superuser_api_client): | ||||||
|  |     note = factories["moderation.Note"]() | ||||||
|  |     url = reverse("api:v1:manage:moderation:notes-detail", kwargs={"uuid": note.uuid}) | ||||||
|  |     response = superuser_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     assert response.data == serializers.ManageNoteSerializer(note).data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| def test_tag_detail(factories, superuser_api_client): | def test_tag_detail(factories, superuser_api_client): | ||||||
|     tag = factories["tags.Tag"]() |     tag = factories["tags.Tag"]() | ||||||
|     url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) |     url = reverse("api:v1:manage:tags-detail", kwargs={"name": tag.name}) | ||||||
|  | @ -417,3 +461,37 @@ def test_tag_delete(factories, superuser_api_client): | ||||||
|     response = superuser_api_client.delete(url) |     response = superuser_api_client.delete(url) | ||||||
| 
 | 
 | ||||||
|     assert response.status_code == 204 |     assert response.status_code == 204 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_report_detail(factories, superuser_api_client): | ||||||
|  |     report = factories["moderation.Report"]() | ||||||
|  |     url = reverse( | ||||||
|  |         "api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid} | ||||||
|  |     ) | ||||||
|  |     response = superuser_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     assert response.data["summary"] == report.summary | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_report_list(factories, superuser_api_client, settings): | ||||||
|  |     report = factories["moderation.Report"]() | ||||||
|  |     url = reverse("api:v1:manage:moderation:reports-list") | ||||||
|  |     response = superuser_api_client.get(url) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  | 
 | ||||||
|  |     assert response.data["count"] == 1 | ||||||
|  |     assert response.data["results"][0]["summary"] == report.summary | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def test_report_update(factories, superuser_api_client): | ||||||
|  |     report = factories["moderation.Report"]() | ||||||
|  |     url = reverse( | ||||||
|  |         "api:v1:manage:moderation:reports-detail", kwargs={"uuid": report.uuid} | ||||||
|  |     ) | ||||||
|  |     response = superuser_api_client.patch(url, {"is_handled": True}) | ||||||
|  | 
 | ||||||
|  |     assert response.status_code == 200 | ||||||
|  |     report.refresh_from_db() | ||||||
|  |     assert report.is_handled is True | ||||||
|  |  | ||||||
|  | @ -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 pytest | ||||||
|  | import urllib.parse | ||||||
|  | 
 | ||||||
|  | from django.core.serializers.json import DjangoJSONEncoder | ||||||
| 
 | 
 | ||||||
| from funkwhale_api.common import utils as common_utils | from funkwhale_api.common import utils as common_utils | ||||||
| from funkwhale_api.federation import models as federation_models | from funkwhale_api.federation import models as federation_models | ||||||
|  | @ -41,7 +45,6 @@ def test_user_filter_serializer_save(factories): | ||||||
|         ("music.Album", "album", "id", serializers.AlbumStateSerializer), |         ("music.Album", "album", "id", serializers.AlbumStateSerializer), | ||||||
|         ("music.Track", "track", "id", serializers.TrackStateSerializer), |         ("music.Track", "track", "id", serializers.TrackStateSerializer), | ||||||
|         ("music.Library", "library", "uuid", serializers.LibraryStateSerializer), |         ("music.Library", "library", "uuid", serializers.LibraryStateSerializer), | ||||||
|         ("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer), |  | ||||||
|         ( |         ( | ||||||
|             "federation.Actor", |             "federation.Actor", | ||||||
|             "account", |             "account", | ||||||
|  | @ -50,8 +53,8 @@ def test_user_filter_serializer_save(factories): | ||||||
|         ), |         ), | ||||||
|     ], |     ], | ||||||
| ) | ) | ||||||
| def test_report_serializer_save( | def test_report_federated_entity_serializer_save( | ||||||
|     factory_name, target_type, id_field, state_serializer, factories, mocker |     factory_name, target_type, id_field, state_serializer, factories, mocker, settings | ||||||
| ): | ): | ||||||
|     target = factories[factory_name]() |     target = factories[factory_name]() | ||||||
|     target_owner = factories["federation.Actor"]() |     target_owner = factories["federation.Actor"]() | ||||||
|  | @ -72,10 +75,58 @@ def test_report_serializer_save( | ||||||
| 
 | 
 | ||||||
|     report = serializer.save() |     report = serializer.save() | ||||||
| 
 | 
 | ||||||
|  |     expected_state = state_serializer(target).data | ||||||
|  |     expected_state["_target"] = json.loads( | ||||||
|  |         json.dumps(target_data, cls=DjangoJSONEncoder) | ||||||
|  |     ) | ||||||
|  |     expected_state["domain"] = urllib.parse.urlparse(target.fid).hostname | ||||||
|  |     expected_state["is_local"] = ( | ||||||
|  |         expected_state["domain"] == settings.FEDERATION_HOSTNAME | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|     assert report.target == target |     assert report.target == target | ||||||
|     assert report.type == payload["type"] |     assert report.type == payload["type"] | ||||||
|     assert report.summary == payload["summary"] |     assert report.summary == payload["summary"] | ||||||
|     assert report.target_state == state_serializer(target).data |     assert report.target_state == expected_state | ||||||
|  |     assert report.target_owner == target_owner | ||||||
|  |     get_target_owner.assert_called_once_with(target) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "factory_name, target_type, id_field, state_serializer", | ||||||
|  |     [("playlists.Playlist", "playlist", "id", serializers.PlaylistStateSerializer)], | ||||||
|  | ) | ||||||
|  | def test_report_local_entity_serializer_save( | ||||||
|  |     factory_name, target_type, id_field, state_serializer, factories, mocker, settings | ||||||
|  | ): | ||||||
|  |     target = factories[factory_name]() | ||||||
|  |     target_owner = factories["federation.Actor"]() | ||||||
|  |     submitter = factories["federation.Actor"]() | ||||||
|  |     target_data = {"type": target_type, id_field: getattr(target, id_field)} | ||||||
|  |     payload = { | ||||||
|  |         "summary": "Report content", | ||||||
|  |         "type": "illegal_content", | ||||||
|  |         "target": target_data, | ||||||
|  |     } | ||||||
|  |     serializer = serializers.ReportSerializer( | ||||||
|  |         data=payload, context={"submitter": submitter} | ||||||
|  |     ) | ||||||
|  |     get_target_owner = mocker.patch.object( | ||||||
|  |         serializers, "get_target_owner", return_value=target_owner | ||||||
|  |     ) | ||||||
|  |     assert serializer.is_valid(raise_exception=True) is True | ||||||
|  | 
 | ||||||
|  |     report = serializer.save() | ||||||
|  | 
 | ||||||
|  |     expected_state = state_serializer(target).data | ||||||
|  |     expected_state["_target"] = json.loads( | ||||||
|  |         json.dumps(target_data, cls=DjangoJSONEncoder) | ||||||
|  |     ) | ||||||
|  |     expected_state["is_local"] = True | ||||||
|  |     assert report.target == target | ||||||
|  |     assert report.type == payload["type"] | ||||||
|  |     assert report.summary == payload["summary"] | ||||||
|  |     assert report.target_state == expected_state | ||||||
|     assert report.target_owner == target_owner |     assert report.target_owner == target_owner | ||||||
|     get_target_owner.assert_called_once_with(target) |     get_target_owner.assert_called_once_with(target) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -50,6 +50,10 @@ from funkwhale_api.users.oauth import scopes | ||||||
|                 "write:instance:edits", |                 "write:instance:edits", | ||||||
|                 "read:instance:libraries", |                 "read:instance:libraries", | ||||||
|                 "write:instance:libraries", |                 "write:instance:libraries", | ||||||
|  |                 "read:instance:notes", | ||||||
|  |                 "write:instance:notes", | ||||||
|  |                 "read:instance:reports", | ||||||
|  |                 "write:instance:reports", | ||||||
|             }, |             }, | ||||||
|         ), |         ), | ||||||
|         ( |         ( | ||||||
|  | @ -91,6 +95,10 @@ from funkwhale_api.users.oauth import scopes | ||||||
|                 "write:instance:users", |                 "write:instance:users", | ||||||
|                 "read:instance:invitations", |                 "read:instance:invitations", | ||||||
|                 "write:instance:invitations", |                 "write:instance:invitations", | ||||||
|  |                 "read:instance:notes", | ||||||
|  |                 "write:instance:notes", | ||||||
|  |                 "read:instance:reports", | ||||||
|  |                 "write:instance:reports", | ||||||
|             }, |             }, | ||||||
|         ), |         ), | ||||||
|         ( |         ( | ||||||
|  | @ -126,6 +134,10 @@ from funkwhale_api.users.oauth import scopes | ||||||
|                 "write:instance:accounts", |                 "write:instance:accounts", | ||||||
|                 "read:instance:domains", |                 "read:instance:domains", | ||||||
|                 "write:instance:domains", |                 "write:instance:domains", | ||||||
|  |                 "read:instance:notes", | ||||||
|  |                 "write:instance:notes", | ||||||
|  |                 "read:instance:reports", | ||||||
|  |                 "write:instance:reports", | ||||||
|             }, |             }, | ||||||
|         ), |         ), | ||||||
|         ( |         ( | ||||||
|  |  | ||||||
|  | @ -15,14 +15,15 @@ | ||||||
|     "dateformat": "^3.0.3", |     "dateformat": "^3.0.3", | ||||||
|     "diff": "^4.0.1", |     "diff": "^4.0.1", | ||||||
|     "django-channels": "^1.1.6", |     "django-channels": "^1.1.6", | ||||||
|  |     "fomantic-ui-css": "^2.7", | ||||||
|     "howler": "^2.0.14", |     "howler": "^2.0.14", | ||||||
|     "js-logger": "^1.4.1", |     "js-logger": "^1.4.1", | ||||||
|     "jwt-decode": "^2.2.0", |     "jwt-decode": "^2.2.0", | ||||||
|     "lodash": "^4.17.10", |     "lodash": "^4.17.10", | ||||||
|     "masonry-layout": "^4.2.2", |     "masonry-layout": "^4.2.2", | ||||||
|     "moment": "^2.22.2", |     "moment": "^2.22.2", | ||||||
|     "fomantic-ui-css": "^2.7", |  | ||||||
|     "qs": "^6.7.0", |     "qs": "^6.7.0", | ||||||
|  |     "sanitize-html": "^1.20.1", | ||||||
|     "showdown": "^1.8.6", |     "showdown": "^1.8.6", | ||||||
|     "vue": "^2.5.17", |     "vue": "^2.5.17", | ||||||
|     "vue-gettext": "^2.1.0", |     "vue-gettext": "^2.1.0", | ||||||
|  |  | ||||||
|  | @ -93,8 +93,12 @@ | ||||||
|             <router-link |             <router-link | ||||||
|               v-if="$store.state.auth.availablePermissions['moderation']" |               v-if="$store.state.auth.availablePermissions['moderation']" | ||||||
|               class="item" |               class="item" | ||||||
|               :to="{name: 'manage.moderation.domains.list'}"> |               :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"> | ||||||
|               <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> |               <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> | ||||||
|  |               <div | ||||||
|  |                 v-if="$store.state.ui.notifications.pendingReviewReports > 0" | ||||||
|  |                 :title="labels.pendingReviewReports" | ||||||
|  |                 :class="['ui', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div> | ||||||
|             </router-link> |             </router-link> | ||||||
|             <router-link |             <router-link | ||||||
|               v-if="$store.state.auth.availablePermissions['settings']" |               v-if="$store.state.auth.availablePermissions['settings']" | ||||||
|  |  | ||||||
|  | @ -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> | <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-avatar v-if="avatar" :actor="actor" /> | ||||||
|      {{ actor.full_username | truncate(30) }} |      {{ actor.full_username | truncate(30) }} | ||||||
|   </span> |   </span> | ||||||
|  | @ -11,7 +15,8 @@ import {hashCode, intToRGB} from '@/utils/color' | ||||||
| export default { | export default { | ||||||
|   props: { |   props: { | ||||||
|     actor: {type: Object}, |     actor: {type: Object}, | ||||||
|     avatar: {type: Boolean, default: true} |     avatar: {type: Boolean, default: true}, | ||||||
|  |     admin: {type: Boolean, default: false}, | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | @ -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) | Vue.component('empty-state', EmptyState) | ||||||
| 
 | 
 | ||||||
|  | import ExpandableDiv from '@/components/common/ExpandableDiv' | ||||||
|  | 
 | ||||||
|  | Vue.component('expandable-div', ExpandableDiv) | ||||||
|  | 
 | ||||||
|  | import CollapseLink from '@/components/common/CollapseLink' | ||||||
|  | 
 | ||||||
|  | Vue.component('collapse-link', CollapseLink) | ||||||
|  | 
 | ||||||
|  | import ActionFeedback from '@/components/common/ActionFeedback' | ||||||
|  | 
 | ||||||
|  | Vue.component('action-feedback', ActionFeedback) | ||||||
|  | 
 | ||||||
| export default {} | export default {} | ||||||
|  |  | ||||||
|  | @ -158,6 +158,9 @@ export default { | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     updatedFields () { |     updatedFields () { | ||||||
|  |       if (!this.obj.target) { | ||||||
|  |         return [] | ||||||
|  |       } | ||||||
|       let payload = this.obj.payload |       let payload = this.obj.payload | ||||||
|       let previousState = this.previousState |       let previousState = this.previousState | ||||||
|       let fields = Object.keys(payload) |       let fields = Object.keys(payload) | ||||||
|  |  | ||||||
|  | @ -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: { |         filters: { | ||||||
|           creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), |           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) { |       handler (newValue) { | ||||||
|         if (newValue) { |         if (newValue) { | ||||||
|           this.initModal() |           this.initModal() | ||||||
|  |           this.$emit('show') | ||||||
|           this.control.modal('show') |           this.control.modal('show') | ||||||
|         } else { |         } else { | ||||||
|           if (this.control) { |           if (this.control) { | ||||||
|  |             this.$emit('hide') | ||||||
|             this.control.modal('hide') |             this.control.modal('hide') | ||||||
|             this.control.remove() |             this.control.remove() | ||||||
|           } |           } | ||||||
|  |  | ||||||
|  | @ -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 { | export default { | ||||||
|   clone: require('lodash/clone'), |   clone: require('lodash/clone'), | ||||||
|  |   keys: require('lodash/keys'), | ||||||
|   debounce: require('lodash/debounce'), |   debounce: require('lodash/debounce'), | ||||||
|   get: require('lodash/get'), |   get: require('lodash/get'), | ||||||
|   merge: require('lodash/merge'), |   merge: require('lodash/merge'), | ||||||
|  |  | ||||||
|  | @ -447,7 +447,30 @@ export default new Router({ | ||||||
|               /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" |               /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" | ||||||
|             ), |             ), | ||||||
|           props: true |           props: true | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: "reports", | ||||||
|  |           name: "manage.moderation.reports.list", | ||||||
|  |           component: () => | ||||||
|  |             import( | ||||||
|  |               /* webpackChunkName: "admin" */ "@/views/admin/moderation/ReportsList" | ||||||
|  |             ), | ||||||
|  |           props: route => { | ||||||
|  |             return { | ||||||
|  |               defaultQuery: route.query.q, | ||||||
|  |               updateUrl: true | ||||||
|             } |             } | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           path: "reports/:id", | ||||||
|  |           name: "manage.moderation.reports.detail", | ||||||
|  |           component: () => | ||||||
|  |             import( | ||||||
|  |               /* webpackChunkName: "admin" */ "@/views/admin/moderation/ReportDetail" | ||||||
|  |             ), | ||||||
|  |           props: true | ||||||
|  |         }, | ||||||
|       ] |       ] | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|  |  | ||||||
|  | @ -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) |             resolve(response.data) | ||||||
|           }) |           }) | ||||||
|           dispatch('ui/fetchUnreadNotifications', null, { root: true }) |           dispatch('ui/fetchUnreadNotifications', null, { root: true }) | ||||||
|  |           if (response.data.permissions.library) { | ||||||
|             dispatch('ui/fetchPendingReviewEdits', null, { root: true }) |             dispatch('ui/fetchPendingReviewEdits', null, { root: true }) | ||||||
|  |           } | ||||||
|  |           if (response.data.permissions.moderation) { | ||||||
|  |             dispatch('ui/fetchPendingReviewReports', null, { root: true }) | ||||||
|  |           } | ||||||
|           dispatch('favorites/fetch', null, { root: true }) |           dispatch('favorites/fetch', null, { root: true }) | ||||||
|           dispatch('moderation/fetchContentFilters', null, { root: true }) |           dispatch('moderation/fetchContentFilters', null, { root: true }) | ||||||
|           dispatch('playlists/fetchOwn', null, { root: true }) |           dispatch('playlists/fetchOwn', null, { root: true }) | ||||||
|  |  | ||||||
|  | @ -14,6 +14,7 @@ export default { | ||||||
|     notifications: { |     notifications: { | ||||||
|       inbox: 0, |       inbox: 0, | ||||||
|       pendingReviewEdits: 0, |       pendingReviewEdits: 0, | ||||||
|  |       pendingReviewReports: 0, | ||||||
|     }, |     }, | ||||||
|     websocketEventsHandlers: { |     websocketEventsHandlers: { | ||||||
|       'inbox.item_added': {}, |       'inbox.item_added': {}, | ||||||
|  | @ -74,6 +75,11 @@ export default { | ||||||
|         commit('notifications', {type: 'pendingReviewEdits', count: response.data.count}) |         commit('notifications', {type: 'pendingReviewEdits', count: response.data.count}) | ||||||
|       }) |       }) | ||||||
|     }, |     }, | ||||||
|  |     fetchPendingReviewReports ({commit, rootState}, payload) { | ||||||
|  |       axios.get('manage/moderation/reports/', {params: {is_handled: 'false', page_size: 1}}).then((response) => { | ||||||
|  |         commit('notifications', {type: 'pendingReviewReports', count: response.data.count}) | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|     websocketEvent ({state}, event) { |     websocketEvent ({state}, event) { | ||||||
|       let handlers = state.websocketEventsHandlers[event.type] |       let handlers = state.websocketEventsHandlers[event.type] | ||||||
|       console.log('Dispatching websocket event', event, handlers) |       console.log('Dispatching websocket event', event, handlers) | ||||||
|  |  | ||||||
|  | @ -49,7 +49,7 @@ | ||||||
| // @import "~fomantic-ui-css/components/ad.css"; | // @import "~fomantic-ui-css/components/ad.css"; | ||||||
| @import "~fomantic-ui-css/components/card.css"; | @import "~fomantic-ui-css/components/card.css"; | ||||||
| // @import "~fomantic-ui-css/components/comment.css"; | // @import "~fomantic-ui-css/components/comment.css"; | ||||||
| // @import "~fomantic-ui-css/components/feed.css"; | @import "~fomantic-ui-css/components/feed.css"; | ||||||
| @import "~fomantic-ui-css/components/item.css"; | @import "~fomantic-ui-css/components/item.css"; | ||||||
| @import "~fomantic-ui-css/components/statistic.css"; | @import "~fomantic-ui-css/components/statistic.css"; | ||||||
| 
 | 
 | ||||||
|  | @ -368,5 +368,20 @@ input + .help { | ||||||
|   margin-top: 0.5em; |   margin-top: 0.5em; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .expandable { | ||||||
|  |   &:not(.expanded) { | ||||||
|  |     overflow: hidden; | ||||||
|  |     max-height: 15vh; | ||||||
|  |     background: linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0.3) 100%); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .ui.borderless.button { | ||||||
|  |   border: none !important; | ||||||
|  |   box-shadow: none !important; | ||||||
|  |   padding-left: 0; | ||||||
|  |   padding-right: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @import "./themes/_light.scss"; | @import "./themes/_light.scss"; | ||||||
| @import "./themes/_dark.scss"; | @import "./themes/_dark.scss"; | ||||||
|  |  | ||||||
|  | @ -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"> |                         class="ui search selection dropdown"> | ||||||
|                         <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option> |                         <option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option> | ||||||
|                       </select> |                       </select> | ||||||
|  |                       <action-feedback :is-loading="updating.permissions"></action-feedback> | ||||||
|                     </td> |                     </td> | ||||||
|                   </tr> |                   </tr> | ||||||
|                   <tr> |                   <tr> | ||||||
|  | @ -308,8 +309,9 @@ | ||||||
|                           name="quota" |                           name="quota" | ||||||
|                           type="number" /> |                           type="number" /> | ||||||
|                         <div class="ui basic label"> |                         <div class="ui basic label"> | ||||||
|                           <translate translate-context="Content/*/*/Unit">MB</translate> |                           <translate translate-context="Content/*/*/Unit">MB</translate>  | ||||||
|                         </div> |                         </div> | ||||||
|  |                         <action-feedback class="ui basic label" size="tiny" :is-loading="updating.upload_quota"></action-feedback> | ||||||
|                       </div> |                       </div> | ||||||
|                     </td> |                     </td> | ||||||
|                   </tr> |                   </tr> | ||||||
|  | @ -403,6 +405,10 @@ export default { | ||||||
|       stats: null, |       stats: null, | ||||||
|       showPolicyForm: false, |       showPolicyForm: false, | ||||||
|       permissions: [], |       permissions: [], | ||||||
|  |       updating: { | ||||||
|  |         permissions: false, | ||||||
|  |         upload_quota: false, | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   created() { |   created() { | ||||||
|  | @ -457,6 +463,8 @@ export default { | ||||||
|       if (toNull && !newValue) { |       if (toNull && !newValue) { | ||||||
|         newValue = null |         newValue = null | ||||||
|       } |       } | ||||||
|  |       let self = this | ||||||
|  |       this.updating[attr] = true | ||||||
|       let params = {} |       let params = {} | ||||||
|       if (attr === "permissions") { |       if (attr === "permissions") { | ||||||
|         params["permissions"] = {} |         params["permissions"] = {} | ||||||
|  | @ -471,12 +479,14 @@ export default { | ||||||
|           logger.default.info( |           logger.default.info( | ||||||
|             `${attr} was updated succcessfully to ${newValue}` |             `${attr} was updated succcessfully to ${newValue}` | ||||||
|           ) |           ) | ||||||
|  |           self.updating[attr] = false | ||||||
|         }, |         }, | ||||||
|         error => { |         error => { | ||||||
|           logger.default.error( |           logger.default.error( | ||||||
|             `Error while setting ${attr} to ${newValue}`, |             `Error while setting ${attr} to ${newValue}`, | ||||||
|             error |             error | ||||||
|           ) |           ) | ||||||
|  |           self.updating[attr] = false | ||||||
|         } |         } | ||||||
|       ) |       ) | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  | @ -1,6 +1,9 @@ | ||||||
| <template> | <template> | ||||||
|   <div class="main pusher"  v-title="labels.moderation"> |   <div class="main pusher"  v-title="labels.moderation"> | ||||||
|     <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> |     <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> | ||||||
|  |       <router-link | ||||||
|  |         class="ui item" | ||||||
|  |         :to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link> | ||||||
|       <router-link |       <router-link | ||||||
|         class="ui item" |         class="ui item" | ||||||
|         :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link> |         :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link> | ||||||
|  |  | ||||||
|  | @ -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: |   dependencies: | ||||||
|     array-uniq "^1.0.1" |     array-uniq "^1.0.1" | ||||||
| 
 | 
 | ||||||
| array-uniq@^1.0.1: | array-uniq@^1.0.1, array-uniq@^1.0.2: | ||||||
|   version "1.0.3" |   version "1.0.3" | ||||||
|   resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" |   resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" | ||||||
|   integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= |   integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= | ||||||
|  | @ -4485,7 +4485,7 @@ html-webpack-plugin@^3.2.0: | ||||||
|     toposort "^1.0.0" |     toposort "^1.0.0" | ||||||
|     util.promisify "1.0.0" |     util.promisify "1.0.0" | ||||||
| 
 | 
 | ||||||
| htmlparser2@^3.3.0, htmlparser2@^3.8.2, htmlparser2@^3.9.1: | htmlparser2@^3.10.0, htmlparser2@^3.3.0, htmlparser2@^3.8.2, htmlparser2@^3.9.1: | ||||||
|   version "3.10.1" |   version "3.10.1" | ||||||
|   resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" |   resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" | ||||||
|   integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== |   integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== | ||||||
|  | @ -5401,16 +5401,36 @@ locate-path@^3.0.0: | ||||||
|     p-locate "^3.0.0" |     p-locate "^3.0.0" | ||||||
|     path-exists "^3.0.0" |     path-exists "^3.0.0" | ||||||
| 
 | 
 | ||||||
|  | lodash.clonedeep@^4.5.0: | ||||||
|  |   version "4.5.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" | ||||||
|  |   integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= | ||||||
|  | 
 | ||||||
| lodash.defaultsdeep@^4.6.0: | lodash.defaultsdeep@^4.6.0: | ||||||
|   version "4.6.0" |   version "4.6.0" | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz#bec1024f85b1bd96cbea405b23c14ad6443a6f81" |   resolved "https://registry.yarnpkg.com/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz#bec1024f85b1bd96cbea405b23c14ad6443a6f81" | ||||||
|   integrity sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E= |   integrity sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E= | ||||||
| 
 | 
 | ||||||
|  | lodash.escaperegexp@^4.1.2: | ||||||
|  |   version "4.1.2" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" | ||||||
|  |   integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= | ||||||
|  | 
 | ||||||
| lodash.get@^4.4.2: | lodash.get@^4.4.2: | ||||||
|   version "4.4.2" |   version "4.4.2" | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" |   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" | ||||||
|   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= |   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= | ||||||
| 
 | 
 | ||||||
|  | lodash.isplainobject@^4.0.6: | ||||||
|  |   version "4.0.6" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" | ||||||
|  |   integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= | ||||||
|  | 
 | ||||||
|  | lodash.isstring@^4.0.1: | ||||||
|  |   version "4.0.1" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" | ||||||
|  |   integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= | ||||||
|  | 
 | ||||||
| lodash.kebabcase@^4.1.1: | lodash.kebabcase@^4.1.1: | ||||||
|   version "4.1.1" |   version "4.1.1" | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" |   resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" | ||||||
|  | @ -5426,6 +5446,11 @@ lodash.memoize@^4.1.2: | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" |   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" | ||||||
|   integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= |   integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= | ||||||
| 
 | 
 | ||||||
|  | lodash.mergewith@^4.6.1: | ||||||
|  |   version "4.6.2" | ||||||
|  |   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" | ||||||
|  |   integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== | ||||||
|  | 
 | ||||||
| lodash.sortby@^4.7.0: | lodash.sortby@^4.7.0: | ||||||
|   version "4.7.0" |   version "4.7.0" | ||||||
|   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" |   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" | ||||||
|  | @ -7806,6 +7831,22 @@ safe-regex@^1.1.0: | ||||||
|   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" |   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" | ||||||
|   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== |   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== | ||||||
| 
 | 
 | ||||||
|  | sanitize-html@^1.20.1: | ||||||
|  |   version "1.20.1" | ||||||
|  |   resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-1.20.1.tgz#f6effdf55dd398807171215a62bfc21811bacf85" | ||||||
|  |   integrity sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA== | ||||||
|  |   dependencies: | ||||||
|  |     chalk "^2.4.1" | ||||||
|  |     htmlparser2 "^3.10.0" | ||||||
|  |     lodash.clonedeep "^4.5.0" | ||||||
|  |     lodash.escaperegexp "^4.1.2" | ||||||
|  |     lodash.isplainobject "^4.0.6" | ||||||
|  |     lodash.isstring "^4.0.1" | ||||||
|  |     lodash.mergewith "^4.6.1" | ||||||
|  |     postcss "^7.0.5" | ||||||
|  |     srcset "^1.0.0" | ||||||
|  |     xtend "^4.0.1" | ||||||
|  | 
 | ||||||
| sass-graph@^2.2.4: | sass-graph@^2.2.4: | ||||||
|   version "2.2.4" |   version "2.2.4" | ||||||
|   resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" |   resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" | ||||||
|  | @ -8243,6 +8284,14 @@ sprintf-js@~1.0.2: | ||||||
|   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" |   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" | ||||||
|   integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= |   integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= | ||||||
| 
 | 
 | ||||||
|  | srcset@^1.0.0: | ||||||
|  |   version "1.0.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/srcset/-/srcset-1.0.0.tgz#a5669de12b42f3b1d5e83ed03c71046fc48f41ef" | ||||||
|  |   integrity sha1-pWad4StC87HV6D7QPHEEb8SPQe8= | ||||||
|  |   dependencies: | ||||||
|  |     array-uniq "^1.0.2" | ||||||
|  |     number-is-nan "^1.0.0" | ||||||
|  | 
 | ||||||
| sshpk@^1.7.0: | sshpk@^1.7.0: | ||||||
|   version "1.16.1" |   version "1.16.1" | ||||||
|   resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" |   resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" | ||||||
|  | @ -9472,6 +9521,11 @@ xtend@^4.0.0, xtend@~4.0.1: | ||||||
|   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" |   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" | ||||||
|   integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= |   integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= | ||||||
| 
 | 
 | ||||||
|  | xtend@^4.0.1: | ||||||
|  |   version "4.0.2" | ||||||
|  |   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" | ||||||
|  |   integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== | ||||||
|  | 
 | ||||||
| y18n@^3.2.1: | y18n@^3.2.1: | ||||||
|   version "3.2.1" |   version "3.2.1" | ||||||
|   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" |   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 Eliot Berriot
						Eliot Berriot