diff --git a/api/config/api_urls.py b/api/config/api_urls.py index ab01e623c3e5bd298ee17fe79d5fdecd604a1aee..93138e9a5df1df3c8239c5d3740f1b2a5504a6ed 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns from rest_framework_jwt import views as jwt_views from funkwhale_api.activity import views as activity_views +from funkwhale_api.common import views as common_views from funkwhale_api.music import views from funkwhale_api.playlists import views as playlists_views from funkwhale_api.subsonic.views import SubsonicViewSet @@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists") router.register( r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks" ) +router.register(r"mutations", common_views.MutationViewSet, "mutations") v1_patterns = router.urls subsonic_router = routers.SimpleRouter(trailing_slash=False) diff --git a/api/config/asgi.py b/api/config/asgi.py index 886178cc28ab9640bfdfa6efca1289402c2cbcf7..b4a8105de7117efe9820da558bc10f019e8eb960 100644 --- a/api/config/asgi.py +++ b/api/config/asgi.py @@ -1,9 +1,9 @@ import os -import django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +import django # noqa django.setup() from .routing import application # noqa - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 91691f2a5bf93a6cb1f9947165df1a7cf9dbdc13..5f69c36d55016c49efa0adffba1bd51afdeff2f1 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None) if env_file: # we have an explicitely specified env file # so we try to load and it fail loudly if it does not exist - print("ENV_FILE", env_file) env.read_env(env_file) else: # we try to load from .env and config/.env @@ -150,7 +149,7 @@ if RAVEN_ENABLED: # Apps specific for this project go here. LOCAL_APPS = ( - "funkwhale_api.common", + "funkwhale_api.common.apps.CommonConfig", "funkwhale_api.activity.apps.ActivityConfig", "funkwhale_api.users", # custom users app # Your stuff: custom apps go here diff --git a/api/config/settings/local.py b/api/config/settings/local.py index d6a8ce484caf782cdd8e9ed2ea66efbdb816fc31..632eb320156901f8e24be123796d4b899a27ba8f 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False # Your local stuff: Below this line define 3rd party library settings -LOGGING = { - "version": 1, - "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}}, - "loggers": { - "django.request": { - "handlers": ["console"], - "propagate": True, - "level": "DEBUG", - }, - "django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"}, - "": {"level": "DEBUG", "handlers": ["console"]}, - }, -} CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS] diff --git a/api/funkwhale_api/common/admin.py b/api/funkwhale_api/common/admin.py index 4124a69b895fbdc5fe51e44d53b99810ac113a7d..3ec6f1f449cf1382e3c2677e0c2e9f8f1cba5319 100644 --- a/api/funkwhale_api/common/admin.py +++ b/api/funkwhale_api/common/admin.py @@ -1,6 +1,9 @@ from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa from django.db.models.fields.related import RelatedField +from . import models +from . import tasks + def register(model): """ @@ -17,3 +20,28 @@ def register(model): return initial_register(model)(modeladmin) return decorator + + +def apply(modeladmin, request, queryset): + queryset.update(is_approved=True) + for id in queryset.values_list("id", flat=True): + tasks.apply_mutation.delay(mutation_id=id) + + +apply.short_description = "Approve and apply" + + +@register(models.Mutation) +class MutationAdmin(ModelAdmin): + list_display = [ + "uuid", + "type", + "created_by", + "creation_date", + "applied_date", + "is_approved", + "is_applied", + ] + search_fields = ["created_by__preferred_username"] + list_filter = ["type", "is_approved", "is_applied"] + actions = [apply] diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..cd671be291395b438ebd15a9caa42f53a81a51c6 --- /dev/null +++ b/api/funkwhale_api/common/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig, apps + +from . import mutations + + +class CommonConfig(AppConfig): + name = "funkwhale_api.common" + + def ready(self): + super().ready() + + app_names = [app.name for app in apps.app_configs.values()] + mutations.registry.autodiscover(app_names) diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py index 71992eff3f9eacfe546df84d77a8faf4924138c2..b93f149f0b0e6f001107d835264545659f42a715 100644 --- a/api/funkwhale_api/common/decorators.py +++ b/api/funkwhale_api/common/decorators.py @@ -1,5 +1,17 @@ -from rest_framework import response +from django.db import transaction + from rest_framework import decorators +from rest_framework import exceptions +from rest_framework import response +from rest_framework import status + +from . import filters +from . import models +from . import mutations as common_mutations +from . import serializers +from . import signals +from . import tasks +from . import utils def action_route(serializer_class): @@ -12,3 +24,67 @@ def action_route(serializer_class): return response.Response(result, status=200) return action + + +def mutations_route(types): + """ + Given a queryset and a list of mutation types, return a view + that can be included in any viewset, and serve: + + GET /{id}/mutations/ - list of mutations for the given object + POST /{id}/mutations/ - create a mutation for the given object + """ + + @transaction.atomic + def mutations(self, request, *args, **kwargs): + obj = self.get_object() + if request.method == "GET": + queryset = models.Mutation.objects.get_for_target(obj).filter( + type__in=types + ) + queryset = queryset.order_by("-creation_date") + filterset = filters.MutationFilter(request.GET, queryset=queryset) + page = self.paginate_queryset(filterset.qs) + if page is not None: + serializer = serializers.APIMutationSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = serializers.APIMutationSerializer(queryset, many=True) + return response.Response(serializer.data) + if request.method == "POST": + if not request.user.is_authenticated: + raise exceptions.NotAuthenticated() + serializer = serializers.APIMutationSerializer( + data=request.data, context={"registry": common_mutations.registry} + ) + serializer.is_valid(raise_exception=True) + if not common_mutations.registry.has_perm( + actor=request.user.actor, + type=serializer.validated_data["type"], + obj=obj, + perm="approve" + if serializer.validated_data.get("is_approved", False) + else "suggest", + ): + raise exceptions.PermissionDenied() + + final_payload = common_mutations.registry.get_validated_payload( + type=serializer.validated_data["type"], + payload=serializer.validated_data["payload"], + obj=obj, + ) + mutation = serializer.save( + created_by=request.user.actor, + target=obj, + payload=final_payload, + is_approved=serializer.validated_data.get("is_approved", None), + ) + if mutation.is_approved: + utils.on_commit(tasks.apply_mutation.delay, mutation_id=mutation.pk) + + utils.on_commit( + signals.mutation_created.send, sender=None, mutation=mutation + ) + return response.Response(serializer.data, status=status.HTTP_201_CREATED) + + return decorators.action(methods=["get", "post"], detail=True)(mutations) diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..6919f9c3771ec81c9e4019cfa9e42d4d30e99494 --- /dev/null +++ b/api/funkwhale_api/common/factories.py @@ -0,0 +1,25 @@ +import factory + +from funkwhale_api.factories import registry, NoUpdateOnCreate + +from funkwhale_api.federation import factories as federation_factories + + +@registry.register +class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): + fid = factory.Faker("federation_url") + uuid = factory.Faker("uuid4") + created_by = factory.SubFactory(federation_factories.ActorFactory) + summary = factory.Faker("paragraph") + type = "update" + + class Meta: + model = "common.Mutation" + + @factory.post_generation + def target(self, create, extracted, **kwargs): + if not create: + # Simple build, do nothing. + return + self.target = extracted + self.save() diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index a0f10efe3a22cb2c903336a073b905f06994e69c..47e673cb5b567cfebfc8ad14ef6aae07acf44687 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -1,4 +1,5 @@ import django_filters +from django import forms from django.db import models from . import search @@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter): def filter(self, qs, value): if not value: return qs - cleaned = self.config.clean(value) + try: + cleaned = self.config.clean(value) + except forms.ValidationError: + return qs.none() return search.apply(qs, cleaned) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py new file mode 100644 index 0000000000000000000000000000000000000000..4825d3b5d8d4a934d458faead03baa693f178b94 --- /dev/null +++ b/api/funkwhale_api/common/filters.py @@ -0,0 +1,126 @@ +from django import forms +from django.db.models import Q + +from django_filters import widgets +from django_filters import rest_framework as filters + +from . import fields +from . import models +from . import search + + +class NoneObject(object): + def __eq__(self, other): + return other.__class__ == NoneObject + + +NONE = NoneObject() +NULL_BOOLEAN_CHOICES = [ + (True, True), + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + (False, False), + ("false", False), + ("False", False), + ("0", False), + ("no", False), + ("None", NONE), + ("none", NONE), + ("Null", NONE), + ("null", NONE), +] + + +class CoerceChoiceField(forms.ChoiceField): + """ + Same as forms.ChoiceField but will return the second value + in the choices tuple instead of the user provided one + """ + + def clean(self, value): + if value is None: + return value + v = super().clean(value) + try: + return [b for a, b in self.choices if v == a][0] + except IndexError: + raise forms.ValidationError("Invalid value {}".format(value)) + + +class NullBooleanFilter(filters.ChoiceFilter): + field_class = CoerceChoiceField + + def __init__(self, *args, **kwargs): + self.choices = NULL_BOOLEAN_CHOICES + kwargs["choices"] = self.choices + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if value in ["", None]: + return qs + if value == NONE: + value = None + qs = self.get_method(qs)( + **{"%s__%s" % (self.field_name, self.lookup_expr): value} + ) + return qs.distinct() if self.distinct else qs + + +def clean_null_boolean_filter(v): + v = CoerceChoiceField(choices=NULL_BOOLEAN_CHOICES).clean(v) + if v == NONE: + v = None + + return v + + +def get_null_boolean_filter(name): + return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})} + + +class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField): + def valid_value(self, value): + return True + + +class QueryArrayWidget(widgets.QueryArrayWidget): + """ + Until https://github.com/carltongibson/django-filter/issues/1047 is fixed + """ + + def value_from_datadict(self, data, files, name): + data = data.copy() + return super().value_from_datadict(data, files, name) + + +class MultipleQueryFilter(filters.TypedMultipleChoiceFilter): + field_class = DummyTypedMultipleChoiceField + + def __init__(self, *args, **kwargs): + kwargs["widget"] = QueryArrayWidget() + super().__init__(*args, **kwargs) + self.lookup_expr = "in" + + +class MutationFilter(filters.FilterSet): + is_approved = NullBooleanFilter("is_approved") + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "summary": {"to": "summary"}, + "fid": {"to": "fid"}, + "type": {"to": "type"}, + }, + filter_fields={ + "domain": {"to": "created_by__domain__name__iexact"}, + "is_approved": get_null_boolean_filter("is_approved"), + "is_applied": {"to": "is_applied"}, + }, + ) + ) + + class Meta: + model = models.Mutation + fields = ["is_approved", "is_applied", "type"] diff --git a/api/funkwhale_api/common/migrations/0002_mutation.py b/api/funkwhale_api/common/migrations/0002_mutation.py new file mode 100644 index 0000000000000000000000000000000000000000..f1f756fd3a173e462954e6760e5b73f976532061 --- /dev/null +++ b/api/funkwhale_api/common/migrations/0002_mutation.py @@ -0,0 +1,91 @@ +# Generated by Django 2.1.5 on 2019-01-31 15:44 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("federation", "0017_auto_20190130_0926"), + ("contenttypes", "0002_remove_content_type_name"), + ("common", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Mutation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("fid", models.URLField(db_index=True, max_length=500, unique=True)), + ( + "uuid", + models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + ("type", models.CharField(db_index=True, max_length=100)), + ("is_approved", models.NullBooleanField(default=None)), + ("is_applied", models.NullBooleanField(default=None)), + ( + "creation_date", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ( + "applied_date", + models.DateTimeField(blank=True, db_index=True, null=True), + ), + ("summary", models.TextField(max_length=2000, blank=True, null=True)), + ("payload", django.contrib.postgres.fields.jsonb.JSONField()), + ( + "previous_state", + django.contrib.postgres.fields.jsonb.JSONField( + null=True, default=None + ), + ), + ("target_id", models.IntegerField(null=True)), + ( + "approved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_mutations", + to="federation.Actor", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_mutations", + to="federation.Actor", + ), + ), + ( + "target_content_type", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="targeting_mutations", + to="contenttypes.ContentType", + ), + ), + ], + ) + ] diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py new file mode 100644 index 0000000000000000000000000000000000000000..1b9cc1e57c9b8063c3719ab9431d75a8c1a131d5 --- /dev/null +++ b/api/funkwhale_api/common/models.py @@ -0,0 +1,89 @@ +import uuid + +from django.contrib.postgres.fields import JSONField +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction +from django.utils import timezone +from django.urls import reverse + +from funkwhale_api.federation import utils as federation_utils + + +class MutationQuerySet(models.QuerySet): + def get_for_target(self, target): + content_type = ContentType.objects.get_for_model(target) + return self.filter(target_content_type=content_type, target_id=target.pk) + + +class Mutation(models.Model): + fid = models.URLField(unique=True, max_length=500, db_index=True) + uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) + created_by = models.ForeignKey( + "federation.Actor", + related_name="created_mutations", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + approved_by = models.ForeignKey( + "federation.Actor", + related_name="approved_mutations", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + type = models.CharField(max_length=100, db_index=True) + # None = no choice, True = approved, False = refused + is_approved = models.NullBooleanField(default=None) + + # None = not applied, True = applied, False = failed + is_applied = models.NullBooleanField(default=None) + creation_date = models.DateTimeField(default=timezone.now, db_index=True) + applied_date = models.DateTimeField(null=True, blank=True, db_index=True) + summary = models.TextField(max_length=2000, null=True, blank=True) + + payload = JSONField() + previous_state = JSONField(null=True, default=None) + + target_id = models.IntegerField(null=True) + target_content_type = models.ForeignKey( + ContentType, + null=True, + on_delete=models.CASCADE, + related_name="targeting_mutations", + ) + target = GenericForeignKey("target_content_type", "target_id") + + objects = MutationQuerySet.as_manager() + + def get_federation_id(self): + if self.fid: + return self.fid + + return federation_utils.full_url( + reverse("federation:edits-detail", kwargs={"uuid": self.uuid}) + ) + + def save(self, **kwargs): + if not self.pk and not self.fid: + self.fid = self.get_federation_id() + + return super().save(**kwargs) + + @transaction.atomic + def apply(self): + from . import mutations + + if self.is_applied: + raise ValueError("Mutation was already applied") + + previous_state = mutations.registry.apply( + type=self.type, obj=self.target, payload=self.payload + ) + self.previous_state = previous_state + self.is_applied = True + self.applied_date = timezone.now() + self.save(update_fields=["is_applied", "applied_date", "previous_state"]) + return previous_state diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..11624e9f629312ce66b35e021a41efddbb683e2f --- /dev/null +++ b/api/funkwhale_api/common/mutations.py @@ -0,0 +1,150 @@ +import persisting_theory + +from rest_framework import serializers + +from django.db import models + + +class ConfNotFound(KeyError): + pass + + +class Registry(persisting_theory.Registry): + look_into = "mutations" + + def connect(self, type, klass, perm_checkers=None): + def decorator(serializer_class): + t = self.setdefault(type, {}) + t[klass] = { + "serializer_class": serializer_class, + "perm_checkers": perm_checkers or {}, + } + return serializer_class + + return decorator + + def apply(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + serializer.is_valid(raise_exception=True) + previous_state = serializer.get_previous_state(obj, serializer.validated_data) + serializer.apply(obj, serializer.validated_data) + return previous_state + + def is_valid(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + return serializer.is_valid(raise_exception=True) + + def get_validated_payload(self, type, obj, payload): + conf = self.get_conf(type, obj) + serializer = conf["serializer_class"](obj, data=payload) + serializer.is_valid(raise_exception=True) + return serializer.payload_serialize(serializer.validated_data) + + def has_perm(self, perm, type, obj, actor): + if perm not in ["approve", "suggest"]: + raise ValueError("Invalid permission {}".format(perm)) + conf = self.get_conf(type, obj) + checker = conf["perm_checkers"].get(perm) + if not checker: + return False + return checker(obj=obj, actor=actor) + + def get_conf(self, type, obj): + try: + type_conf = self[type] + except KeyError: + raise ConfNotFound("{} is not a registered mutation".format(type)) + + try: + conf = type_conf[obj.__class__] + except KeyError: + try: + conf = type_conf[None] + except KeyError: + raise ConfNotFound( + "No mutation configuration found for {}".format(obj.__class__) + ) + return conf + + +class MutationSerializer(serializers.Serializer): + def apply(self, obj, validated_data): + raise NotImplementedError() + + def get_previous_state(self, obj, validated_data): + return + + def payload_serialize(self, data): + return data + + +class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): + serialized_relations = {} + + def __init__(self, *args, **kwargs): + # we force partial mode, because update mutations are partial + kwargs.setdefault("partial", True) + super().__init__(*args, **kwargs) + + def apply(self, obj, validated_data): + return self.update(obj, validated_data) + + def validate(self, validated_data): + if not validated_data: + raise serializers.ValidationError("You must update at least one field") + + return super().validate(validated_data) + + def db_serialize(self, validated_data): + data = {} + # ensure model fields are serialized properly + for key, value in list(validated_data.items()): + if not isinstance(value, models.Model): + data[key] = value + continue + field = self.serialized_relations[key] + data[key] = getattr(value, field) + return data + + def payload_serialize(self, data): + data = super().payload_serialize(data) + # we use our serialized_relations configuration + # to ensure we store ids instead of model instances in our json + # payload + for field, attr in self.serialized_relations.items(): + data[field] = getattr(data[field], attr) + return data + + def create(self, validated_data): + validated_data = self.db_serialize(validated_data) + return super().create(validated_data) + + def get_previous_state(self, obj, validated_data): + return get_update_previous_state( + obj, + *list(validated_data.keys()), + serialized_relations=self.serialized_relations + ) + + +def get_update_previous_state(obj, *fields, serialized_relations={}): + if not fields: + raise ValueError("You need to provide at least one field") + + state = {} + for field in fields: + value = getattr(obj, field) + if isinstance(value, models.Model): + # we store the related object id and repr for better UX + id_field = serialized_relations[field] + related_value = getattr(value, id_field) + state[field] = {"value": related_value, "repr": str(value)} + else: + state[field] = {"value": value} + + return state + + +registry = Registry() diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 70aecd632f6e77109dc3e8d51e06695acd19d6cf..622cb29dd174d1c7dbc3ece052b6e8ab5a5068a4 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -103,9 +103,7 @@ class SearchConfig: return matching = [t for t in tokens if t["key"] in self.filter_fields] - queries = [ - Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching - ] + queries = [self.get_filter_query(token) for token in matching] query = None for q in queries: if not query: @@ -114,6 +112,26 @@ class SearchConfig: query = query & q return query + def get_filter_query(self, token): + raw_value = token["value"] + try: + field = self.filter_fields[token["key"]]["field"] + value = field.clean(raw_value) + except KeyError: + # no cleaning to apply + value = raw_value + try: + query_field = self.filter_fields[token["key"]]["to"] + return Q(**{query_field: value}) + except KeyError: + pass + + # we don't have a basic filter -> field mapping, this likely means we + # have a dynamic handler in the config + handler = self.filter_fields[token["key"]]["handler"] + value = handler(value) + return value + def clean_types(self, tokens): if not self.types: return [] diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index fafa6152d09edf95052f2346b16e3135756a6114..59b513f37aa057d843df6a4a5405381be71c2c8f 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ +from . import models + class RelatedField(serializers.RelatedField): default_error_messages = { @@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField): return SimpleUploadedFile( file_obj.name, content, content_type=file_obj.content_type ) + + +from funkwhale_api.federation import serializers as federation_serializers # noqa + +TARGET_ID_TYPE_MAPPING = { + "music.Track": ("id", "track"), + "music.Artist": ("id", "artist"), + "music.Album": ("id", "album"), +} + + +class APIMutationSerializer(serializers.ModelSerializer): + created_by = federation_serializers.APIActorSerializer(read_only=True) + target = serializers.SerializerMethodField() + + class Meta: + model = models.Mutation + fields = [ + "fid", + "uuid", + "type", + "creation_date", + "applied_date", + "is_approved", + "is_applied", + "created_by", + "approved_by", + "summary", + "payload", + "previous_state", + "target", + ] + read_only_fields = [ + "uuid", + "creation_date", + "fid", + "is_applied", + "created_by", + "approved_by", + "previous_state", + ] + + def get_target(self, obj): + target = obj.target + if not target: + return + + id_field, type = TARGET_ID_TYPE_MAPPING[target._meta.label] + return {"type": type, "id": getattr(target, id_field), "repr": str(target)} + + def validate_type(self, value): + if value not in self.context["registry"]: + raise serializers.ValidationError("Invalid mutation type {}".format(value)) + return value diff --git a/api/funkwhale_api/common/signals.py b/api/funkwhale_api/common/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..1d8e953ccf7fed7033579ea68ce5aeb1f047bed9 --- /dev/null +++ b/api/funkwhale_api/common/signals.py @@ -0,0 +1,6 @@ +import django.dispatch + +mutation_created = django.dispatch.Signal(providing_args=["mutation"]) +mutation_updated = django.dispatch.Signal( + providing_args=["mutation", "old_is_approved", "new_is_approved"] +) diff --git a/api/funkwhale_api/common/tasks.py b/api/funkwhale_api/common/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..994b0bdfff13a27a5eec0e99a87c72c11b39287d --- /dev/null +++ b/api/funkwhale_api/common/tasks.py @@ -0,0 +1,59 @@ +from django.db import transaction +from django.dispatch import receiver + + +from funkwhale_api.common import channels +from funkwhale_api.taskapp import celery + +from . import models +from . import serializers +from . import signals + + +@celery.app.task(name="common.apply_mutation") +@transaction.atomic +@celery.require_instance( + models.Mutation.objects.exclude(is_applied=True).select_for_update(), "mutation" +) +def apply_mutation(mutation): + mutation.apply() + + +@receiver(signals.mutation_created) +def broadcast_mutation_created(mutation, **kwargs): + group = "instance_activity" + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.created", + "mutation": serializers.APIMutationSerializer(mutation).data, + "pending_review_count": models.Mutation.objects.filter( + is_approved=None + ).count(), + }, + }, + ) + + +@receiver(signals.mutation_updated) +def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwargs): + group = "instance_activity" + channels.group_send( + group, + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.updated", + "mutation": serializers.APIMutationSerializer(mutation).data, + "pending_review_count": models.Mutation.objects.filter( + is_approved=None + ).count(), + "old_is_approved": old_is_approved, + "new_is_approved": new_is_approved, + }, + }, + ) diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index fe7d6733aba97fa481adc85b5ce4c19a1beee9ce..743c95095b1f1042b3e411fe6cb784a18e5d1dd6 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -1,3 +1,21 @@ +from django.db import transaction + +from rest_framework.decorators import action +from rest_framework import exceptions +from rest_framework import mixins +from rest_framework import permissions +from rest_framework import response +from rest_framework import viewsets + +from . import filters +from . import models +from . import mutations +from . import serializers +from . import signals +from . import tasks +from . import utils + + class SkipFilterForGetObject: def get_object(self, *args, **kwargs): setattr(self.request, "_skip_filters", True) @@ -7,3 +25,98 @@ class SkipFilterForGetObject: if getattr(self.request, "_skip_filters", False): return queryset return super().filter_queryset(queryset) + + +class MutationViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + models.Mutation.objects.all() + .order_by("-creation_date") + .select_related("created_by", "approved_by") + .prefetch_related("target") + ) + serializer_class = serializers.APIMutationSerializer + permission_classes = [permissions.IsAuthenticated] + ordering_fields = ("creation_date",) + filterset_class = filters.MutationFilter + + def perform_destroy(self, instance): + if instance.is_applied: + raise exceptions.PermissionDenied("You cannot delete an applied mutation") + + actor = self.request.user.actor + is_owner = actor == instance.created_by + + if not any( + [ + is_owner, + mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ), + ] + ): + raise exceptions.PermissionDenied() + + return super().perform_destroy(instance) + + @action(detail=True, methods=["post"]) + @transaction.atomic + def approve(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_applied: + return response.Response( + {"error": "This mutation was already applied"}, status=403 + ) + actor = self.request.user.actor + can_approve = mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ) + + if not can_approve: + raise exceptions.PermissionDenied() + previous_is_approved = instance.is_approved + instance.approved_by = actor + instance.is_approved = True + instance.save(update_fields=["approved_by", "is_approved"]) + utils.on_commit(tasks.apply_mutation.delay, mutation_id=instance.id) + utils.on_commit( + signals.mutation_updated.send, + sender=None, + mutation=instance, + old_is_approved=previous_is_approved, + new_is_approved=instance.is_approved, + ) + return response.Response({}, status=200) + + @action(detail=True, methods=["post"]) + @transaction.atomic + def reject(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_applied: + return response.Response( + {"error": "This mutation was already applied"}, status=403 + ) + actor = self.request.user.actor + can_approve = mutations.registry.has_perm( + perm="approve", type=instance.type, obj=instance.target, actor=actor + ) + + if not can_approve: + raise exceptions.PermissionDenied() + previous_is_approved = instance.is_approved + instance.approved_by = actor + instance.is_approved = False + instance.save(update_fields=["approved_by", "is_approved"]) + utils.on_commit( + signals.mutation_updated.send, + sender=None, + mutation=instance, + old_is_approved=previous_is_approved, + new_is_approved=instance.is_approved, + ) + return response.Response({}, status=200) diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py index f8347d1ebf47c2f300d6b7d0941809d7457d7ed2..f7d5006da0cdf50df601da96e78fc5b8e97d00ca 100644 --- a/api/funkwhale_api/federation/urls.py +++ b/api/funkwhale_api/federation/urls.py @@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False) router.register(r"federation/shared", views.SharedViewSet, "shared") router.register(r"federation/actors", views.ActorViewSet, "actors") +router.register(r"federation/edits", views.EditViewSet, "edits") router.register(r".well-known", views.WellKnownViewSet, "well-known") music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries") diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index 3b322e915144bec01e536640022402a1b20429bf..13791ec213665834a854d42ac953eeb72e35d934 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV return response.Response({}) +class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + lookup_field = "uuid" + authentication_classes = [authentication.SignatureAuthentication] + permission_classes = [] + renderer_classes = [renderers.ActivityPubRenderer] + # queryset = common_models.Mutation.objects.local().select_related() + # serializer_class = serializers.ActorSerializer + + class WellKnownViewSet(viewsets.GenericViewSet): authentication_classes = [] permission_classes = [] diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 3134ae19c987f086d3b4d80ce1ceb4abb903e976..fa5a10f6d4397ed296d8710d6b76d6dc6646ec28 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -1,6 +1,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import filters as common_filters from funkwhale_api.common import search from funkwhale_api.moderation import filters as moderation_filters @@ -28,12 +29,14 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet): class TrackFilter(moderation_filters.HiddenContentFilterSet): q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) playable = filters.BooleanFilter(field_name="_", method="filter_playable") + id = common_filters.MultipleQueryFilter(coerce=int) class Meta: model = models.Track fields = { "title": ["exact", "iexact", "startswith", "icontains"], "playable": ["exact"], + "id": ["exact"], "artist": ["exact"], "album": ["exact"], "license": ["exact"], diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..51efa0ab8cd70f7c241c460937a76b75ee8ab658 --- /dev/null +++ b/api/funkwhale_api/music/mutations.py @@ -0,0 +1,24 @@ +from funkwhale_api.common import mutations + +from . import models + + +def can_suggest(obj, actor): + return True + + +def can_approve(obj, actor): + return actor.user and actor.user.get_permissions()["library"] + + +@mutations.registry.connect( + "update", + models.Track, + perm_checkers={"suggest": can_suggest, "approve": can_approve}, +) +class TrackMutationSerializer(mutations.UpdateMutationSerializer): + serialized_relations = {"license": "code"} + + class Meta: + model = models.Track + fields = ["license", "title", "position"] diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index d07fd27ec40f9a7173e563c9c50f132238adb13d..f6bed500c154b5320522207f18cde25d9d917690 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -15,6 +15,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from taggit.models import Tag +from funkwhale_api.common import decorators as common_decorators from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils @@ -186,6 +187,8 @@ class TrackViewSet( "artist__name", ) + mutations = common_decorators.mutations_route(types=["update"]) + def get_queryset(self): queryset = super().get_queryset() filter_favorites = self.request.GET.get("favorites", None) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index c75604f6eb94ea97b26247604b13bf2c01fa6dfe..79ef045618310cf908776180f1ea95c743867481 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -94,6 +94,7 @@ class UserWriteSerializer(serializers.ModelSerializer): class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() + full_username = serializers.SerializerMethodField() avatar = avatar_field class Meta: @@ -101,6 +102,7 @@ class UserReadSerializer(serializers.ModelSerializer): fields = [ "id", "username", + "full_username", "name", "email", "is_staff", @@ -114,6 +116,10 @@ class UserReadSerializer(serializers.ModelSerializer): def get_permissions(self, o): return o.get_permissions() + def get_full_username(self, o): + if o.actor: + return o.actor.full_username + class MeSerializer(UserReadSerializer): quota_status = serializers.SerializerMethodField() diff --git a/api/tests/common/test_decorators.py b/api/tests/common/test_decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..66e692585a5bca823c87cf2a8b5de5b5efa4d8ae --- /dev/null +++ b/api/tests/common/test_decorators.py @@ -0,0 +1,122 @@ +import pytest + +from rest_framework import viewsets + +from funkwhale_api.common import decorators +from funkwhale_api.common import models +from funkwhale_api.common import mutations +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks +from funkwhale_api.music import models as music_models +from funkwhale_api.music import licenses + + +class V(viewsets.ModelViewSet): + queryset = music_models.Track.objects.all() + mutations = decorators.mutations_route(types=["update"]) + permission_classes = [] + + +def test_mutations_route_list(factories, api_request): + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload="") + factories["common.Mutation"](target=track, type="noop", payload="") + + view = V.as_view({"get": "mutations"}) + expected = { + "next": None, + "previous": None, + "count": 1, + "results": [serializers.APIMutationSerializer(mutation).data], + } + + request = api_request.get("/") + response = view(request, pk=track.pk) + + assert response.status_code == 200 + assert response.data == expected + + +@pytest.mark.parametrize("is_approved", [False, True]) +def test_mutations_route_create_success(factories, api_request, is_approved, mocker): + licenses.load(licenses.LICENSES) + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + user = factories["users.User"](permission_library=True) + actor = user.create_actor() + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + + request = api_request.post( + "/", + { + "type": "update", + "payload": {"title": "bar", "unknown": "test", "license": "cc-by-nc-4.0"}, + "summary": "hello", + "is_approved": is_approved, + }, + format="json", + ) + setattr(request, "user", user) + setattr(request, "session", {}) + response = view(request, pk=track.pk) + + assert response.status_code == 201 + + mutation = models.Mutation.objects.get_for_target(track).latest("id") + + assert mutation.type == "update" + assert mutation.payload == {"title": "bar", "license": "cc-by-nc-4.0"} + assert mutation.created_by == actor + assert mutation.is_approved is is_approved + assert mutation.is_applied is None + assert mutation.target == track + assert mutation.summary == "hello" + + if is_approved: + on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.pk) + expected = serializers.APIMutationSerializer(mutation).data + assert response.data == expected + on_commit.assert_any_call( + signals.mutation_created.send, mutation=mutation, sender=None + ) + + +def test_mutations_route_create_no_auth(factories, api_request): + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + + request = api_request.post("/", {}, format="json") + response = view(request, pk=track.pk) + + assert response.status_code == 401 + + +@pytest.mark.parametrize("is_approved", [False, True]) +def test_mutations_route_create_no_perm(factories, api_request, mocker, is_approved): + track = factories["music.Track"](title="foo") + view = V.as_view({"post": "mutations"}) + user = factories["users.User"]() + actor = user.create_actor() + has_perm = mocker.patch.object(mutations.registry, "has_perm", return_value=False) + request = api_request.post( + "/", + { + "type": "update", + "payload": {"title": "bar", "unknown": "test"}, + "summary": "hello", + "is_approved": is_approved, + }, + format="json", + ) + setattr(request, "user", user) + setattr(request, "session", {}) + response = view(request, pk=track.pk) + + assert response.status_code == 403 + has_perm.assert_called_once_with( + actor=actor, + obj=track, + type="update", + perm="approve" if is_approved else "suggest", + ) diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..2e89dfa37c1dc2800569aca928b9452e0b2d29fe --- /dev/null +++ b/api/tests/common/test_filters.py @@ -0,0 +1,38 @@ +import pytest + +from funkwhale_api.common import filters + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, True), + ("True", True), + ("true", True), + ("1", True), + ("yes", True), + (False, False), + ("False", False), + ("false", False), + ("0", False), + ("no", False), + ("None", None), + ("none", None), + ("Null", None), + ("null", None), + ], +) +def test_mutation_filter_is_approved(value, expected, factories): + mutations = { + True: factories["common.Mutation"](is_approved=True, payload={}), + False: factories["common.Mutation"](is_approved=False, payload={}), + None: factories["common.Mutation"](is_approved=None, payload={}), + } + + qs = mutations[True].__class__.objects.all() + + filterset = filters.MutationFilter( + {"q": "is_approved:{}".format(value)}, queryset=qs + ) + + assert list(filterset.qs) == [mutations[expected]] diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..25c9befda809de508864b39f7bdc551f6600b14b --- /dev/null +++ b/api/tests/common/test_models.py @@ -0,0 +1,17 @@ +import pytest + +from django.urls import reverse + +from funkwhale_api.federation import utils as federation_utils + + +@pytest.mark.parametrize( + "model,factory_args,namespace", + [("common.Mutation", {"created_by__local": True}, "federation:edits-detail")], +) +def test_mutation_fid_is_populated(factories, model, factory_args, namespace): + instance = factories[model](**factory_args, fid=None, payload={}) + + assert instance.fid == federation_utils.full_url( + reverse(namespace, kwargs={"uuid": instance.uuid}) + ) diff --git a/api/tests/common/test_mutations.py b/api/tests/common/test_mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..bb2a08500b5c45ab73043257f01b35954b400674 --- /dev/null +++ b/api/tests/common/test_mutations.py @@ -0,0 +1,141 @@ +import pytest + +from funkwhale_api.common import mutations + +from rest_framework import serializers + + +@pytest.fixture +def mutations_registry(): + return mutations.Registry() + + +def test_apply_mutation(mutations_registry): + class Obj: + pass + + obj = Obj() + + @mutations_registry.connect("foo", Obj) + class S(mutations.MutationSerializer): + foo = serializers.ChoiceField(choices=["bar", "baz"]) + + def apply(self, obj, validated_data): + setattr(obj, "foo", validated_data["foo"]) + + with pytest.raises(mutations.ConfNotFound): + mutations_registry.apply("foo", object(), payload={"foo": "nope"}) + + with pytest.raises(serializers.ValidationError): + mutations_registry.apply("foo", obj, payload={"foo": "nope"}) + + mutations_registry.apply("foo", obj, payload={"foo": "bar"}) + + assert obj.foo == "bar" + + +def test_apply_update_mutation(factories, mutations_registry, mocker): + user = factories["users.User"](email="hello@test.email") + get_update_previous_state = mocker.patch.object( + mutations, "get_update_previous_state" + ) + + @mutations_registry.connect("update", user.__class__) + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["username", "email"] + + previous_state = mutations_registry.apply( + "update", user, payload={"username": "foo"} + ) + assert previous_state == get_update_previous_state.return_value + get_update_previous_state.assert_called_once_with( + user, "username", serialized_relations={} + ) + user.refresh_from_db() + + assert user.username == "foo" + assert user.email == "hello@test.email" + + +def test_db_serialize_update_mutation(factories, mutations_registry, mocker): + user = factories["users.User"](email="hello@test.email", with_actor=True) + + class S(mutations.UpdateMutationSerializer): + serialized_relations = {"actor": "full_username"} + + class Meta: + model = user.__class__ + fields = ["actor"] + + expected = {"actor": user.actor.full_username} + assert S().db_serialize({"actor": user.actor}) == expected + + +def test_is_valid_mutation(factories, mutations_registry): + user = factories["users.User"].build() + + @mutations_registry.connect("update", user.__class__) + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["email"] + + with pytest.raises(serializers.ValidationError): + mutations_registry.is_valid("update", user, payload={"email": "foo"}) + mutations_registry.is_valid("update", user, payload={"email": "foo@bar.com"}) + + +@pytest.mark.parametrize("perm", ["approve", "suggest"]) +def test_permissions(perm, factories, mutations_registry, mocker): + actor = factories["federation.Actor"].build() + user = factories["users.User"].build() + + class S(mutations.UpdateMutationSerializer): + class Meta: + model = user.__class__ + fields = ["email"] + + mutations_registry.connect("update", user.__class__)(S) + + assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is False + + checker = mocker.Mock(return_value=True) + mutations_registry.connect("update", user.__class__, perm_checkers={perm: checker})( + S + ) + + assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is True + checker.assert_called_once_with(obj=user, actor=actor) + + +def test_model_apply(factories, mocker, now): + target = factories["music.Artist"]() + mutation = factories["common.Mutation"](type="noop", target=target, payload="hello") + + apply = mocker.patch.object( + mutations.registry, "apply", return_value={"previous": "state"} + ) + + mutation.apply() + apply.assert_called_once_with(type="noop", obj=target, payload="hello") + mutation.refresh_from_db() + + assert mutation.is_applied is True + assert mutation.previous_state == {"previous": "state"} + assert mutation.applied_date == now + + +def test_get_previous_state(factories): + obj = factories["music.Track"]() + expected = { + "title": {"value": obj.title}, + "album": {"value": obj.album.pk, "repr": str(obj.album)}, + } + assert ( + mutations.get_update_previous_state( + obj, "title", "album", serialized_relations={"album": "pk"} + ) + == expected + ) diff --git a/api/tests/common/test_search.py b/api/tests/common/test_search.py index e5be7bc900f0d215f27909603390dd115fbb68d8..8872298025658d27ec174470ffd193ca6f64c321 100644 --- a/api/tests/common/test_search.py +++ b/api/tests/common/test_search.py @@ -1,6 +1,7 @@ import pytest from django.db.models import Q +from django import forms from funkwhale_api.common import search from funkwhale_api.music import models as music_models @@ -45,6 +46,24 @@ def test_search_config_query(query, expected): assert cleaned["search_query"] == expected +def test_search_config_query_filter_field_handler(): + s = search.SearchConfig( + filter_fields={"account": {"handler": lambda v: Q(hello="world")}} + ) + + cleaned = s.clean("account:noop") + assert cleaned["filter_query"] == Q(hello="world") + + +def test_search_config_query_filter_field(): + s = search.SearchConfig( + filter_fields={"account": {"to": "noop", "field": forms.BooleanField()}} + ) + + cleaned = s.clean("account:true") + assert cleaned["filter_query"] == Q(noop=True) + + @pytest.mark.parametrize( "query,expected", [ diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..f097c44231af27de220df3889cf7f1db2fe48ea0 --- /dev/null +++ b/api/tests/common/test_tasks.py @@ -0,0 +1,65 @@ +import pytest + +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks + + +def test_apply_migration(factories, mocker): + mutation = factories["common.Mutation"](payload={}) + apply = mocker.patch.object(mutation.__class__, "apply") + tasks.apply_mutation(mutation_id=mutation.pk) + + apply.assert_called_once_with() + + +def test_broadcast_mutation_created(factories, mocker): + mutation = factories["common.Mutation"](payload={}) + factories["common.Mutation"](payload={}, is_approved=True) + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + expected = serializers.APIMutationSerializer(mutation).data + + signals.mutation_created.send(sender=None, mutation=mutation) + group_send.assert_called_with( + "instance_activity", + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.created", + "mutation": expected, + "pending_review_count": 1, + }, + }, + ) + + +def test_broadcast_mutation_updated(factories, mocker): + mutation = factories["common.Mutation"](payload={}, is_approved=True) + factories["common.Mutation"](payload={}) + group_send = mocker.patch("funkwhale_api.common.channels.group_send") + expected = serializers.APIMutationSerializer(mutation).data + + signals.mutation_updated.send( + sender=None, mutation=mutation, old_is_approved=False, new_is_approved=True + ) + group_send.assert_called_with( + "instance_activity", + { + "type": "event.send", + "text": "", + "data": { + "type": "mutation.updated", + "mutation": expected, + "old_is_approved": False, + "new_is_approved": True, + "pending_review_count": 1, + }, + }, + ) + + +def test_cannot_apply_already_applied_migration(factories): + mutation = factories["common.Mutation"](payload={}, is_applied=True) + with pytest.raises(mutation.__class__.DoesNotExist): + tasks.apply_mutation(mutation_id=mutation.pk) diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..9a03fb429284f7955222a006deb7d055501d25e9 --- /dev/null +++ b/api/tests/common/test_views.py @@ -0,0 +1,161 @@ +import pytest +from django.urls import reverse + +from funkwhale_api.common import serializers +from funkwhale_api.common import signals +from funkwhale_api.common import tasks + + +def test_can_detail_mutation(logged_in_api_client, factories): + mutation = factories["common.Mutation"](payload={}) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.get(url) + + expected = serializers.APIMutationSerializer(mutation).data + + assert response.status_code == 200 + assert response.data == expected + + +def test_can_list_mutations(logged_in_api_client, factories): + mutation = factories["common.Mutation"](payload={}) + url = reverse("api:v1:mutations-list") + + response = logged_in_api_client.get(url) + + expected = serializers.APIMutationSerializer(mutation).data + + assert response.status_code == 200 + assert response.data["results"] == [expected] + + +def test_can_destroy_mutation_creator(logged_in_api_client, factories): + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"]( + target=track, type="update", payload={}, created_by=actor + ) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + + +def test_can_destroy_mutation_not_creator(logged_in_api_client, factories): + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](type="update", target=track, payload={}) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 403 + + mutation.refresh_from_db() + + +def test_can_destroy_mutation_has_perm(logged_in_api_client, factories, mocker): + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + has_perm = mocker.patch( + "funkwhale_api.common.mutations.registry.has_perm", return_value=True + ) + url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + has_perm.assert_called_once_with( + obj=mutation.target, type=mutation.type, perm="approve", actor=actor + ) + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_can_approve_reject_mutation_with_perm( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + actor = logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + has_perm = mocker.patch( + "funkwhale_api.common.mutations.registry.has_perm", return_value=True + ) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + has_perm.assert_called_once_with( + obj=mutation.target, type=mutation.type, perm="approve", actor=actor + ) + + if expected: + on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.id) + mutation.refresh_from_db() + + assert mutation.is_approved == expected + assert mutation.approved_by == actor + + on_commit.assert_any_call( + signals.mutation_updated.send, + mutation=mutation, + sender=None, + new_is_approved=expected, + old_is_approved=None, + ) + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_cannot_approve_reject_applied_mutation( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"]( + target=track, type="update", payload={}, is_applied=True + ) + mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=True) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 403 + on_commit.assert_not_called() + + mutation.refresh_from_db() + + assert mutation.is_approved is None + assert mutation.approved_by is None + + +@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)]) +def test_cannot_approve_reject_without_perm( + endpoint, expected, logged_in_api_client, factories, mocker +): + on_commit = mocker.patch("funkwhale_api.common.utils.on_commit") + logged_in_api_client.user.create_actor() + track = factories["music.Track"]() + mutation = factories["common.Mutation"](target=track, type="update", payload={}) + mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=False) + url = reverse( + "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid} + ) + + response = logged_in_api_client.post(url) + + assert response.status_code == 403 + on_commit.assert_not_called() + + mutation.refresh_from_db() + + assert mutation.is_approved is None + assert mutation.approved_by is None diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py new file mode 100644 index 0000000000000000000000000000000000000000..d6b8223d4efe9397710227028703ca6573155983 --- /dev/null +++ b/api/tests/music/test_mutations.py @@ -0,0 +1,35 @@ +from funkwhale_api.music import licenses + + +def test_track_license_mutation(factories, now): + track = factories["music.Track"](license=None) + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"license": "cc-by-sa-4.0"} + ) + licenses.load(licenses.LICENSES) + mutation.apply() + track.refresh_from_db() + + assert track.license.code == "cc-by-sa-4.0" + + +def test_track_title_mutation(factories, now): + track = factories["music.Track"](title="foo") + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"title": "bar"} + ) + mutation.apply() + track.refresh_from_db() + + assert track.title == "bar" + + +def test_track_position_mutation(factories): + track = factories["music.Track"](position=4) + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"position": 12} + ) + mutation.apply() + track.refresh_from_db() + + assert track.position == 12 diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 741fe9b29cd62f749aba0addf41e5cfdff60303c..b11f9b0065a4b42c84e49473cb3d88cbf57ed266 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -70,6 +70,19 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client): assert response.data == expected +def test_track_list_filter_id(api_request, factories, logged_in_api_client): + track1 = factories["music.Track"]() + track2 = factories["music.Track"]() + factories["music.Track"]() + url = reverse("api:v1:tracks-list") + response = logged_in_api_client.get(url, {"id[]": [track1.id, track2.id]}) + + assert response.status_code == 200 + assert response.data["count"] == 2 + assert response.data["results"][0]["id"] == track2.id + assert response.data["results"][1]["id"] == track1.id + + @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")]) def test_artist_view_filter_playable(param, expected, factories, api_request): artists = { diff --git a/front/package.json b/front/package.json index adeb1fb19d6bc64a4bee8b1f099245fc285875db..22b5f3bb5ed35aebd5cb562c5f752ec2a65f8533 100644 --- a/front/package.json +++ b/front/package.json @@ -13,6 +13,7 @@ "dependencies": { "axios": "^0.18.0", "dateformat": "^3.0.3", + "diff": "^4.0.1", "django-channels": "^1.1.6", "howler": "^2.0.14", "js-logger": "^1.4.1", diff --git a/front/src/App.vue b/front/src/App.vue index 1cc6a2d3cadaa313a7876016a5f33674a7d4f2e7..fd94a9f4605dc69d386d864f3663c5d928e8e4e8 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -92,6 +92,16 @@ export default { id: 'sidebarCount', handler: this.incrementNotificationCountInSidebar }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'mutation.created', + id: 'sidebarReviewEditCount', + handler: this.incrementReviewEditCountInSidebar + }) + this.$store.commit('ui/addWebsocketEventHandler', { + eventName: 'mutation.updated', + id: 'sidebarReviewEditCount', + handler: this.incrementReviewEditCountInSidebar + }) }, mounted () { let self = this @@ -110,12 +120,23 @@ export default { eventName: 'inbox.item_added', id: 'sidebarCount', }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'mutation.created', + id: 'sidebarReviewEditCount', + }) + this.$store.commit('ui/removeWebsocketEventHandler', { + eventName: 'mutation.updated', + id: 'sidebarReviewEditCount', + }) this.disconnect() }, methods: { incrementNotificationCountInSidebar (event) { this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) }, + incrementReviewEditCountInSidebar (event) { + this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count}) + }, fetchNodeInfo () { let self = this axios.get('instance/nodeinfo/2.0/').then(response => { @@ -179,7 +200,6 @@ export default { }), suggestedInstances () { let instances = this.$store.state.instance.knownInstances.slice(0) - console.log('instance', instances) if (this.$store.state.instance.frontSettings.defaultServerUrl) { let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl if (!serverUrl.endsWith('/')) { @@ -188,7 +208,6 @@ export default { instances.push(serverUrl) } instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/') - console.log('HELLO', instances) return _.uniq(instances.filter((e) => {return e})) }, version () { diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 865618b12cc8ffd58dec9f50e35b1f15d82e4a3c..77b9c02f5eb3e74db492acdebd648b7a35f48e91 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -97,6 +97,17 @@ :to="{name: 'manage.moderation.domains.list'}"> <i class="shield icon"></i><translate>Moderation</translate> </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['library']" + class="item" + :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> + <i class="book icon"></i><translate>Library</translate> + <div + v-if="$store.state.ui.notifications.pendingReviewEdits > 0" + :title="labels.pendingReviewEdits" + :class="['ui', 'teal', 'label']"> + {{ $store.state.ui.notifications.pendingReviewEdits }}</div> + </router-link> </div> </div> </nav> @@ -210,10 +221,12 @@ export default { let mainMenu = this.$gettext("Main menu") let selectTrack = this.$gettext("Play this track") let pendingFollows = this.$gettext("Pending follow requests") + let pendingReviewEdits = this.$gettext("Pending review edits") return { pendingFollows, mainMenu, - selectTrack + selectTrack, + pendingReviewEdits } }, tracks: { diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 61be521b5d24a5cab6f2c9226453fda48134cdf3..6a1b65cd3aca0ce9ec3ed3ff017150ae3b1ee211 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -97,7 +97,6 @@ export default { username: this.credentials.username, password: this.credentials.password } - console.log('NEXT', this.next) this.$store .dispatch("auth/login", { credentials, diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue new file mode 100644 index 0000000000000000000000000000000000000000..ddd9066f031d24a592984b1cd1e7a4c2ea983f31 --- /dev/null +++ b/front/src/components/common/EmptyState.vue @@ -0,0 +1,40 @@ +<template> + <div class="ui small placeholder segment"> + <div class="ui header"> + <div class="content"> + <slot name="title"> + + <i class="search icon"></i> + <translate :translate-context="'Content/*/Paragraph'"> + No results were found. + </translate> + </slot> + </div> + </div> + <div class="inline"> + <slot></slot> + <button v-if="refresh" class="ui button" @click="$emit('refresh')"> + <translate :translate-context="'Content/Button/Label/Verb'"> + Refresh + </translate></button> + </button> + </div> + </div> +</template> +<script> +export default { + props: { + refresh: {type: Boolean, default: false} + } +} +</script> + +<style> +.ui.small.placeholder.segment { + min-height: auto; +} +.ui.header .content { + text-align: center; + display: block; +} +</style> diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue index eed245ea6ce1538d9b0afcd9bcd6297e5695f3a7..fde04f141399428ff413154775ce51eada0d0623 100644 --- a/front/src/components/common/HumanDate.vue +++ b/front/src/components/common/HumanDate.vue @@ -1,10 +1,16 @@ <template> - <time :datetime="date" :title="date | moment">{{ realDate | ago($store.state.ui.momentLocale) }}</time> + <time :datetime="date" :title="date | moment"> + <i v-if="icon" class="outline clock icon"></i> + {{ realDate | ago($store.state.ui.momentLocale) }} + </time> </template> <script> import {mapState} from 'vuex' export default { - props: ['date'], + props: { + date: {required: true}, + icon: {type: Boolean, required: false, default: false}, + }, computed: { ...mapState({ lastDate: state => state.ui.lastDate diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 99e57095c0735a96c55d99e8fd00827203eaa929..711b227ae956a8943c8085087b0215029a50499d 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -44,5 +44,8 @@ import Tooltip from '@/components/common/Tooltip' Vue.component('tooltip', Tooltip) +import EmptyState from '@/components/common/EmptyState' + +Vue.component('empty-state', EmptyState) export default {} diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..88a512373bbcff4643e1d1127d2a155787191f7d --- /dev/null +++ b/front/src/components/library/EditCard.vue @@ -0,0 +1,209 @@ +<template> + <div class="ui fluid card"> + <div class="content"> + <div class="header"> + <router-link :to="detailUrl"> + <translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.uuid.substring(0, 8)}">Modification %{ id }</translate> + </router-link> + </div> + <div class="meta"> + <router-link + v-if="obj.target && obj.target.type === 'track'" + :to="{name: 'library.tracks.detail', params: {id: obj.target.id }}"> + <i class="music icon"></i> + <translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.target.id, name: obj.target.repr}">Track #%{ id } - %{ name }</translate> + </router-link> + <br> + <human-date :date="obj.creation_date" :icon="true"></human-date> + + <span class="right floated"> + <span v-if="obj.is_approved && obj.is_applied"> + <i class="green check icon"></i> + <translate :translate-context="'Content/Library/Card/Short'">Approved and applied</translate> + </span> + <span v-else-if="obj.is_approved"> + <i class="green check icon"></i> + <translate :translate-context="'Content/Library/Card/Short'">Approved</translate> + </span> + <span v-else-if="obj.is_approved === null"> + <i class="yellow hourglass icon"></i> + <translate :translate-context="'Content/Library/Card/Short'">Pending review</translate> + </span> + <span v-else-if="obj.is_approved === false"> + <i class="red x icon"></i> + <translate :translate-context="'Content/Library/Card/Short'">Rejected</translate> + </span> + </span> + </div> + </div> + <div v-if="obj.summary" class="content"> + {{ obj.summary }} + </div> + <div class="content"> + <table v-if="obj.type === 'update'" class="ui celled very basic fixed stacking table"> + <thead> + <tr> + <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Field</translate></th> + <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Old value</translate></th> + <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">New value</translate></th> + </tr> + </thead> + <tbody> + <tr v-for="field in getUpdatedFields(obj.payload, previousState)" :key="field.id"> + <td>{{ field.id }}</td> + + <td v-if="field.diff"> + <span v-if="!part.added" v-for="part in field.diff" :class="['diff', {removed: part.removed}]"> + {{ part.value }} + </span> + </td> + <td v-else> + <translate :translate-context="'*/*/*'">N/A</translate> + </td> + + <td v-if="field.diff"> + <span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]"> + {{ part.value }} + </span> + </td> + <td v-else>{{ field.new }}</td> + </tr> + </tbody> + </table> + </div> + <div v-if="obj.created_by" class="extra content"> + <actor-link :actor="obj.created_by" /> + </div> + <div v-if="canDelete || canApprove" class="ui bottom attached buttons"> + <button + v-if="canApprove && obj.is_approved !== true" + @click="approve(true)" + :class="['ui', {loading: isLoading}, 'green', 'basic', 'button']"> + <translate :translate-context="'Content/Library/Button.Label'">Approve</translate> + </button> + <button + v-if="canApprove && obj.is_approved === null" + @click="approve(false)" + :class="['ui', {loading: isLoading}, 'yellow', 'basic', 'button']"> + <translate :translate-context="'Content/Library/Button.Label'">Reject</translate> + </button> + <dangerous-button + v-if="canDelete" + :class="['ui', {loading: isLoading}, 'basic button']" + :action="remove"> + <translate :translate-context="'*/*/*/Verb'">Delete</translate> + <p slot="modal-header"><translate :translate-context="'Popup/Library/Title'">Delete this suggestion?</translate></p> + <div slot="modal-content"> + <p><translate :translate-context="'Popup/Library/Paragraph'">The suggestion will be completely removed, this action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate :translate-context="'Popup/Library/Button.Label'">Delete</translate></p> + </dangerous-button> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import { diffWordsWithSpace } from 'diff' + +import edits from '@/edits' + +function castValue (value) { + if (value === null || value === undefined) { + return '' + } + return String(value) +} + +export default { + props: { + obj: {required: true}, + currentState: {required: false} + }, + data () { + return { + isLoading: false + } + }, + computed: { + canApprove: edits.getCanApprove, + canDelete: edits.getCanDelete, + 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.obj.target) { + return '' + } + let namespace + let id = this.obj.target.id + if (this.obj.target.type === 'track') { + namespace = 'library.tracks.edit.detail' + } + if (this.obj.target.type === 'album') { + namespace = 'library.albums.edit.detail' + } + if (this.obj.target.type === 'artist') { + namespace = 'library.artists.edit.detail' + } + return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href + } + }, + methods: { + remove () { + let self = this + this.isLoading = true + axios.delete(`mutations/${this.obj.uuid}/`).then((response) => { + self.$emit('deleted') + self.isLoading = false + }, error => { + self.isLoading = false + }) + }, + approve (approved) { + let url + if (approved) { + url = `mutations/${this.obj.uuid}/approve/` + } else { + url = `mutations/${this.obj.uuid}/reject/` + } + let self = this + this.isLoading = true + axios.post(url).then((response) => { + self.$emit('approved', approved) + self.isLoading = false + self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewEdits'}) + }, error => { + self.isLoading = false + }) + }, + getUpdatedFields (payload, previousState) { + let fields = Object.keys(payload) + return fields.map((f) => { + let d = { + id: f, + } + if (previousState && previousState[f]) { + d.old = previousState[f] + } + d.new = payload[f] + if (d.old) { + // we compute the diffs between the old and new values + + let oldValue = castValue(d.old.value) + let newValue = castValue(d.new) + d.diff = diffWordsWithSpace(oldValue, newValue) + } + return d + }) + } + } +} +</script> diff --git a/front/src/components/library/EditDetail.vue b/front/src/components/library/EditDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..4a0c89434de8797d37022d0d9916df1fd719647a --- /dev/null +++ b/front/src/components/library/EditDetail.vue @@ -0,0 +1,52 @@ +<template> + + <section :class="['ui', 'vertical', 'stripe', {loading: isLoading}, 'segment']"> + <div class="ui text container"> + <edit-card v-if="obj" :obj="obj" :current-state="currentState" /> + </div> + </section> +</template> + +<script> +import axios from "axios" +import edits from '@/edits' +import EditCard from '@/components/library/EditCard' +export default { + props: ["object", "objectType", "editId"], + components: { + EditCard + }, + data () { + return { + isLoading: true, + obj: null, + } + }, + created () { + this.fetchData() + }, + computed: { + configs: edits.getConfigs, + config: edits.getConfig, + currentState: edits.getCurrentState, + currentState () { + let self = this + let s = {} + this.config.fields.forEach(f => { + s[f.id] = {value: f.getValue(self.object)} + }) + return s + } + }, + methods: { + fetchData () { + var self = this + this.isLoading = true + axios.get(`mutations/${this.editId}/`).then(response => { + self.obj = response.data + self.isLoading = false + }) + } + } +} +</script> diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..e98a4a6567d749d9a4e92a21a9686f38d37ef45c --- /dev/null +++ b/front/src/components/library/EditForm.vue @@ -0,0 +1,192 @@ +<template> + <div v-if="submittedMutation"> + <div class="ui positive message"> + <div class="header"><translate :translate-context="'Content/Library/Paragraph'">Your edit was successfully submitted.</translate></div> + </div> + <edit-card :obj="submittedMutation" :current-state="currentState" /> + <button class="ui button" @click.prevent="submittedMutation = null"> + <translate :translate-context="'Content/Library/Button.Label'"> + Submit another edit + </translate> + </button> + </div> + <div v-else> + + <edit-list :filters="editListFilters" :url="mutationsUrl" :obj="object" :currentState="currentState"> + <div slot="title"> + <template v-if="showPendingReview"> + <translate :translate-context="'Content/Library/Paragraph'"> + Recent edits awaiting review + </translate> + <button class="ui tiny basic right floated button" @click.prevent="showPendingReview = false"> + <translate :translate-context="'Content/Library/Button.Label'"> + Show all edits + </translate> + </button> + </template> + <template v-else> + <translate :translate-context="'Content/Library/Paragraph'"> + Recent edits + </translate> + <button class="ui tiny basic right floated button" @click.prevent="showPendingReview = true"> + <translate :translate-context="'Content/Library/Button.Label'"> + Retrict to unreviewed edits + </translate> + </button> + </template> + </div> + <empty-state slot="empty-state"> + <translate :translate-context="'Content/Library/Paragraph'"> + Suggest a change using the form below. + </translate> + </empty-state> + </edit-list> + <form class="ui form" @submit.prevent="submit()"> + <div class="ui hidden divider"></div> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate :translate-context="'Content/Library/Error message.Title'">Error while submitting edit</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div v-if="!canEdit" class="ui message"> + <translate :translate-context="'Content/Library/Paragraph'"> + You don't have the permission to edit this object, but you can suggest changes. Once submitted, suggestions will be reviewed before approval. + </translate> + </div> + <div v-if="values" v-for="fieldConfig in config.fields" :key="fieldConfig.id" class="ui field"> + <template v-if="fieldConfig.type === 'text'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + <input :type="fieldConfig.inputType || 'text'" v-model="values[fieldConfig.id]" :required="fieldConfig.required" :name="fieldConfig.id" :id="fieldConfig.id"> + </template> + <div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]"> + <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]"> + <i class="undo icon"></i> + <translate :translate-context="'Content/Library/Button.Label'" :translate-params="{value: initialValues[fieldConfig.id]}">Reset to initial value: %{ value }</translate> + </button> + </div> + </div> + <div class="field"> + <label for="summary"><translate :translate-context="'*/*/*'">Summary (optional)</translate></label> + <textarea name="change-summary" v-model="summary" id="change-summary" rows="3" :placeholder="labels.summaryPlaceholder"></textarea> + </div> + <router-link + class="ui left floated button" + v-if="objectType === 'track'" + :to="{name: 'library.tracks.detail', params: {id: object.id }}" + > + <translate :translate-context="'Content/*/Button.Label'">Cancel</translate> + </router-link> + <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit" :disabled="isLoading || !mutationPayload"> + <translate v-if="canEdit" key="1" :translate-context="'Content/Library/Button.Label/Verb'">Submit and apply edit</translate> + <translate v-else key="2" :translate-context="'Content/Library/Button.Label/Verb'">Submit suggestion</translate> + </button> + </form> + </div> + </div> +</template> + +<script> +import _ from '@/lodash' +import axios from "axios" +import EditList from '@/components/library/EditList' +import EditCard from '@/components/library/EditCard' +import edits from '@/edits' + +export default { + props: ["objectType", "object"], + components: { + EditList, + EditCard + }, + data() { + return { + isLoading: false, + errors: [], + values: {}, + initialValues: {}, + summary: '', + submittedMutation: null, + showPendingReview: true, + } + }, + created () { + this.setValues() + }, + computed: { + configs: edits.getConfigs, + config: edits.getConfig, + currentState: edits.getCurrentState, + canEdit: edits.getCanEdit, + labels () { + return { + summaryPlaceholder: this.$pgettext('*/*/Placeholder', 'A short summary describing your changes.'), + } + }, + mutationsUrl () { + if (this.objectType === 'track') { + return `tracks/${this.object.id}/mutations/` + } + }, + mutationPayload () { + let self = this + let changedFields = this.config.fields.filter(f => { + return self.values[f.id] != self.initialValues[f.id] + }) + if (changedFields.length === 0) { + return null + } + let payload = { + type: 'update', + payload: {}, + summary: this.summary, + } + changedFields.forEach((f) => { + payload.payload[f.id] = self.values[f.id] + }) + return payload + }, + editListFilters () { + if (this.showPendingReview) { + return {is_approved: 'null'} + } else { + return {} + } + }, + }, + + methods: { + setValues () { + let self = this + this.config.fields.forEach(f => { + self.$set(self.values, f.id, f.getValue(self.object)) + self.$set(self.initialValues, f.id, self.values[f.id]) + }) + }, + submit() { + let self = this + self.isLoading = true + self.errors = [] + let payload = _.clone(this.mutationPayload || {}) + if (this.canEdit) { + payload.is_approved = true + } + return axios.post(this.mutationsUrl, payload).then( + response => { + self.isLoading = false + self.submittedMutation = response.data + }, + error => { + self.errors = error.backendErrors + self.isLoading = false + } + ) + } + } +} +</script> +<style> +.reset.button { + margin-top: 0.5em; +} +</style> diff --git a/front/src/components/library/EditList.vue b/front/src/components/library/EditList.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ff1fc72a0661e0e9a21c3e97e17fe73aabd1b39 --- /dev/null +++ b/front/src/components/library/EditList.vue @@ -0,0 +1,74 @@ +<template> + <div class="wrapper"> + <h3 class="ui header"> + <slot name="title"></slot> + </h3> + <slot v-if="!isLoading && objects.length === 0" name="empty-state"></slot> + <button v-if="nextPage || previousPage" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button> + <button v-if="nextPage || previousPage" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button> + <div class="ui hidden divider"></div> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <edit-card @updated="fetchData(url)" @deleted="fetchData(url)" v-for="obj in objects" :key="obj.uuid" :obj="obj" :current-state="currentState" /> + </div> +</template> + +<script> +import _ from '@/lodash' +import axios from 'axios' + +import EditCard from '@/components/library/EditCard' + +export default { + props: { + url: {type: String, required: true}, + filters: {type: Object, required: false, default: () => {return {}}}, + currentState: {required: false}, + }, + components: { + EditCard + }, + data () { + return { + objects: [], + limit: 5, + isLoading: false, + errors: null, + previousPage: null, + nextPage: null + } + }, + created () { + this.fetchData(this.url) + }, + methods: { + fetchData (url) { + if (!url) { + return + } + this.isLoading = true + let self = this + let params = _.clone(this.filters) + params.page_size = this.limit + axios.get(url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.objects = response.data.results + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + }, + watch: { + filters: { + handler () { + this.fetchData(this.url) + }, + deep: true + } + } +} +</script> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/TrackBase.vue similarity index 51% rename from front/src/components/library/Track.vue rename to front/src/components/library/TrackBase.vue index 66b17d04b1cba7e378a607565736942a634d530e..c0209732df3698c5bee50b75194de52e339989d0 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/TrackBase.vue @@ -64,99 +64,15 @@ </div> </modal> </template> + <router-link + :to="{name: 'library.tracks.edit', params: {id: track.id }}" + class="ui icon labeled button"> + <i class="edit icon"></i> + <translate :translate-context="'Content/Track/Button.Label/Verb'">Edit…</translate> + </router-link> </div> </section> - <section class="ui vertical stripe center aligned segment"> - <h2 class="ui header"> - <translate :translate-context="'Content/Track/Title/Noun'">Track information</translate> - </h2> - <table class="ui very basic collapsing celled center aligned table"> - <tbody> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate> - </td> - <td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td> - <td v-else> - <translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate> - </td> - </tr> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate> - </td> - <td v-if="license"> - <a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a> - </td> - <td v-else> - <translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate> - </td> - </tr> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label'">Duration</translate> - </td> - <td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - </tr> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label'">Size</translate> - </td> - <td v-if="upload && upload.size">{{ upload.size | humanSize }}</td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - </tr> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate> - </td> - <td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - </tr> - <tr> - <td> - <translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate> - </td> - <td v-if="upload && upload.extension">{{ upload.extension }}</td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - </tr> - </tbody> - </table> - </section> - <section class="ui vertical stripe center aligned segment"> - <h2> - <translate :translate-context="'Content/Track/Title'">Lyrics</translate> - </h2> - <div v-if="isLoadingLyrics" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <div v-if="lyrics" v-html="lyrics.content_rendered"></div> - <template v-if="!isLoadingLyrics & !lyrics"> - <p> - <translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate> - </p> - <a class="ui button" target="_blank" :href="lyricsSearchUrl"> - <i class="search icon"></i> - <translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate> - </a> - </template> - </section> - <section class="ui vertical stripe segment"> - <h2> - <translate :translate-context="'Content/Track/Title'">User libraries</translate> - </h2> - <library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'"> - <translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate> - </library-widget> - </section> + <router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view> </template> </main> </template> @@ -169,7 +85,6 @@ import logger from "@/logging" import PlayButton from "@/components/audio/PlayButton" import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon" import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon" -import LibraryWidget from "@/components/federation/LibraryWidget" import Modal from '@/components/semantic/Modal' import EmbedWizard from "@/components/audio/EmbedWizard" @@ -181,7 +96,6 @@ export default { PlayButton, TrackPlaylistIcon, TrackFavoriteIcon, - LibraryWidget, Modal, EmbedWizard }, @@ -189,17 +103,13 @@ export default { return { time, isLoadingTrack: true, - isLoadingLyrics: true, track: null, - lyrics: null, - licenseData: null, - libraries: [], - showEmbedModal: false + showEmbedModal: false, + libraries: [] } }, created() { this.fetchData() - this.fetchLyrics() }, methods: { fetchData() { @@ -212,29 +122,6 @@ export default { self.isLoadingTrack = false }) }, - fetchLicenseData(licenseId) { - var self = this - let url = `licenses/${licenseId}/` - axios.get(url).then(response => { - self.licenseData = response.data - }) - }, - fetchLyrics() { - var self = this - this.isLoadingLyrics = true - let url = FETCH_URL + this.id + "/lyrics/" - logger.default.debug('Fetching lyrics for track "' + this.id + '"') - axios.get(url).then( - response => { - self.lyrics = response.data - self.isLoadingLyrics = false - }, - response => { - console.error("No lyrics available") - self.isLoadingLyrics = false - } - ) - } }, computed: { publicLibraries () { @@ -242,16 +129,16 @@ export default { return l.privacy_level === 'everyone' }) }, - labels() { - return { - title: this.$pgettext('Head/Track/Title', "Track") - } - }, upload() { if (this.track.uploads) { return this.track.uploads[0] } }, + labels() { + return { + title: this.$pgettext('Head/Track/Title', "Track") + } + }, wikipediaUrl() { return ( "https://en.wikipedia.org/w/index.php?search=" + @@ -276,11 +163,6 @@ export default { } return u }, - lyricsSearchUrl() { - let base = "http://lyrics.wikia.com/wiki/Special:Search?query=" - let query = this.track.artist.name + ":" + this.track.title - return base + encodeURI(query) - }, cover() { return null }, @@ -302,30 +184,11 @@ export default { ")" ) }, - license() { - if (!this.track || !this.track.license) { - return null - } - return this.licenseData - } }, watch: { id() { this.fetchData() }, - track (v) { - if (v && v.license) { - this.fetchLicenseData(v.license) - } - } } } </script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -.table.center.aligned { - margin-left: auto; - margin-right: auto; -} -</style> diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f6d27dc1316aaba9fffa6d9890f73edbf86737f --- /dev/null +++ b/front/src/components/library/TrackDetail.vue @@ -0,0 +1,191 @@ +<template> + + <div v-if="track"> + <section class="ui vertical stripe center aligned segment"> + <h2 class="ui header"> + <translate :translate-context="'Content/Track/Title/Noun'">Track information</translate> + </h2> + <table class="ui very basic collapsing celled center aligned table"> + <tbody> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate> + </td> + <td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td> + <td v-else> + <translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate> + </td> + </tr> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate> + </td> + <td v-if="license"> + <a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a> + </td> + <td v-else> + <translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate> + </td> + </tr> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label'">Duration</translate> + </td> + <td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td> + <td v-else> + <translate :translate-context="'*/*/*'">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label'">Size</translate> + </td> + <td v-if="upload && upload.size">{{ upload.size | humanSize }}</td> + <td v-else> + <translate :translate-context="'*/*/*'">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate> + </td> + <td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td> + <td v-else> + <translate :translate-context="'*/*/*'">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate> + </td> + <td v-if="upload && upload.extension">{{ upload.extension }}</td> + <td v-else> + <translate :translate-context="'*/*/*'">N/A</translate> + </td> + </tr> + </tbody> + </table> + </section> + <section class="ui vertical stripe center aligned segment"> + <h2> + <translate :translate-context="'Content/Track/Title'">Lyrics</translate> + </h2> + <div v-if="isLoadingLyrics" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <div v-if="lyrics" v-html="lyrics.content_rendered"></div> + <template v-if="!isLoadingLyrics & !lyrics"> + <p> + <translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate> + </p> + <a class="ui button" target="_blank" :href="lyricsSearchUrl"> + <i class="search icon"></i> + <translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate> + </a> + </template> + </section> + <section class="ui vertical stripe segment"> + <h2> + <translate :translate-context="'Content/Track/Title'">User libraries</translate> + </h2> + <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'tracks/' + id + '/libraries/'"> + <translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate> + </library-widget> + </section> + </div> +</template> + +<script> +import time from "@/utils/time" +import axios from "axios" +import url from "@/utils/url" +import logger from "@/logging" +import LibraryWidget from "@/components/federation/LibraryWidget" + +const FETCH_URL = "tracks/" + +export default { + props: ["track", "libraries"], + components: { + LibraryWidget, + }, + data() { + return { + time, + id: this.track.id, + isLoadingLyrics: true, + lyrics: null, + licenseData: null + } + }, + created() { + this.fetchLyrics() + if (this.track && this.track.license) { + this.fetchLicenseData(this.track.license) + } + }, + methods: { + fetchLicenseData(licenseId) { + var self = this + let url = `licenses/${licenseId}/` + axios.get(url).then(response => { + self.licenseData = response.data + }) + }, + fetchLyrics() { + var self = this + this.isLoadingLyrics = true + let url = FETCH_URL + this.id + "/lyrics/" + logger.default.debug('Fetching lyrics for track "' + this.id + '"') + axios.get(url).then( + response => { + self.lyrics = response.data + self.isLoadingLyrics = false + }, + response => { + console.error("No lyrics available") + self.isLoadingLyrics = false + } + ) + } + }, + computed: { + labels() { + return { + title: this.$pgettext('Head/Track/Title', "Track") + } + }, + upload() { + if (this.track.uploads) { + return this.track.uploads[0] + } + }, + lyricsSearchUrl() { + let base = "http://lyrics.wikia.com/wiki/Special:Search?query=" + let query = this.track.artist.name + ":" + this.track.title + return base + encodeURI(query) + }, + license() { + if (!this.track || !this.track.license) { + return null + } + return this.licenseData + } + }, + watch: { + track (v) { + if (v && v.license) { + this.fetchLicenseData(v.license) + } + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped lang="scss"> +.table.center.aligned { + margin-left: auto; + margin-right: auto; +} +</style> diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue new file mode 100644 index 0000000000000000000000000000000000000000..40178e6ed25968e1cc718fa6d479ae0bcd0db016 --- /dev/null +++ b/front/src/components/library/TrackEdit.vue @@ -0,0 +1,34 @@ +<template> + + <section class="ui vertical stripe segment"> + <div class="ui text container"> + <h2> + <translate v-if="canEdit" key="1" :translate-context="'Content/*/Title'">Edit this track</translate> + <translate v-else key="2" :translate-context="'Content/*/Title'">Suggest an edit on this track</translate> + </h2> + <edit-form :object-type="objectType" :object="object" :can-edit="canEdit"></edit-form> + </div> + </section> +</template> + +<script> +import axios from "axios" + +import EditForm from '@/components/library/EditForm' +export default { + props: ["objectType", "object", "libraries"], + data() { + return { + id: this.object.id + } + }, + components: { + EditForm + }, + computed: { + canEdit () { + return true + } + } +} +</script> diff --git a/front/src/components/manage/library/EditsCardList.vue b/front/src/components/manage/library/EditsCardList.vue new file mode 100644 index 0000000000000000000000000000000000000000..8e51ff0f0c3bea28520a84d4f100d04551ae190f --- /dev/null +++ b/front/src/components/manage/library/EditsCardList.vue @@ -0,0 +1,231 @@ +<template> + <div class="ui text container"> + <slot></slot> + <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'">Status</translate></label> + <select class="ui dropdown" @change="addSearchToken('is_approved', $event.target.value)" :value="getTokenValue('is_approved', '')"> + <option value=""> + <translate :translate-context="'Content/Admin/Dropdown'">All</translate> + </option> + <option value="null"> + <translate :translate-context="'Content/Admin/Dropdown'">Pending review</translate> + </option> + <option value="yes"> + <translate :translate-context="'Content/Admin/Dropdown'">Approved</translate> + </option> + <option value="no"> + <translate :translate-context="'Content/Admin/Dropdown'">Rejected</translate> + </option> + </select> + </div> + <div class="field"> + <label><translate :translate-context="'Content/Search/Dropdown.Label'">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 class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <div v-else-if="result && result.count > 0"> + <edit-card + :obj="obj" + :current-state="getCurrentState(obj.target)" + v-for="obj in result.results" + @deleted="handle('delete', obj.uuid, null)" + @approved="handle('approved', obj.uuid, $event)" + :key="obj.uuid" /> + </div> + <empty-state v-else :refresh="true" @refresh="fetchData()"></empty-state> + </div> + <div class="ui hidden divider"></div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate :translate-context="'Content/Library/Paragraph'" + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</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 EditCard from '@/components/library/EditCard' +import {normalizeQuery, parseTokens} from '@/search' +import SmartSearchMixin from '@/components/mixins/SmartSearch' + +import edits from '@/edits' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + EditCard + }, + 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('mutations/', {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…') + } + }, + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/library/FilesTable.vue b/front/src/components/manage/library/FilesTable.vue deleted file mode 100644 index 4716e361acd959732de011960727ef1d780bd01d..0000000000000000000000000000000000000000 --- a/front/src/components/manage/library/FilesTable.vue +++ /dev/null @@ -1,216 +0,0 @@ -<template> - <div> - <div class="ui inline form"> - <div class="fields"> - <div class="ui field"> - <label><translate :translate-context="'Content/Search/Input.Label'">Search</translate></label> - <input name="search" type="text" v-model="search" :placeholder="labels.searchPlaceholder" /> - </div> - <div class="field"> - <label><translate :translate-context="'Content/Search/Dropdown.Label'">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 class="dimmable"> - <div v-if="isLoading" class="ui active inverted dimmer"> - <div class="ui loader"></div> - </div> - <action-table - v-if="result" - @action-launched="fetchData" - :objects-data="result" - :actions="actions" - :action-url="'manage/library/uploads/action/'" - :filters="actionFilters"> - <template slot="header-cells"> - <th><translate :translate-context="'*/*/*/Short, Noun'">Title</translate></th> - <th><translate :translate-context="'*/*/*/Short, Noun'">Artist</translate></th> - <th><translate :translate-context="'*/*/*/Short, Noun'">Album</translate></th> - <th><translate :translate-context="'Content/Library/Table.Label/Short, Noun'">Import date</translate></th> - <th><translate :translate-context="'Content/Library/Table.Label/Short, Noun'">Type</translate></th> - <th><translate :translate-context="'Content/*/*/Short, Noun'">Bitrate</translate></th> - <th><translate :translate-context="'Content/*/*/Short, Noun'">Duration</translate></th> - <th><translate :translate-context="'Content/*/*/Short, Noun'">Size</translate></th> - </template> - <template slot="row-cells" slot-scope="scope"> - <td> - <span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(30) }}</span> - </td> - <td> - <span :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(30) }}</span> - </td> - <td> - <span :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span> - </td> - <td> - <human-date :date="scope.obj.creation_date"></human-date> - </td> - <td v-if="scope.obj.mimetype"> - {{ scope.obj.mimetype }} - </td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - <td v-if="scope.obj.bitrate"> - {{ scope.obj.bitrate | humanSize }}/s - </td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - <td v-if="scope.obj.duration"> - {{ time.parse(scope.obj.duration) }} - </td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - <td v-if="scope.obj.size"> - {{ scope.obj.size | humanSize }} - </td> - <td v-else> - <translate :translate-context="'*/*/*'">N/A</translate> - </td> - </template> - </action-table> - </div> - <div> - <pagination - v-if="result && result.count > paginateBy" - @page-changed="selectPage" - :compact="true" - :current="page" - :paginate-by="paginateBy" - :total="result.count" - ></pagination> - - <span v-if="result && result.results.length > 0"> - <translate :translate-context="'Content/Library/Paragraph'" - :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> - Showing results %{ start }-%{ end } on %{ total } - </translate> - </span> - </div> - </div> -</template> - -<script> -import axios from 'axios' -import _ from '@/lodash' -import time from '@/utils/time' -import Pagination from '@/components/Pagination' -import ActionTable from '@/components/common/ActionTable' -import OrderingMixin from '@/components/mixins/Ordering' -import TranslationsMixin from '@/components/mixins/Translations' - -export default { - mixins: [OrderingMixin, TranslationsMixin], - props: { - filters: {type: Object, required: false} - }, - components: { - Pagination, - ActionTable - }, - data () { - let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') - return { - time, - isLoading: false, - result: null, - page: 1, - paginateBy: 25, - search: '', - orderingDirection: defaultOrdering.direction || '+', - ordering: defaultOrdering.field, - orderingOptions: [ - ['creation_date', 'creation_date'], - ['accessed_date', 'accessed_date'], - ['modification_date', 'modification_date'], - ['size', 'size'], - ['bitrate', 'bitrate'], - ['duration', 'duration'] - ] - - } - }, - created () { - this.fetchData() - }, - methods: { - fetchData () { - let params = _.merge({ - 'page': this.page, - 'page_size': this.paginateBy, - 'q': this.search, - 'ordering': this.getOrderingAsString() - }, this.filters) - let self = this - self.isLoading = true - self.checked = [] - axios.get('/manage/library/uploads/', {params: params}).then((response) => { - self.result = response.data - self.isLoading = false - }, error => { - self.isLoading = false - self.errors = error.backendErrors - }) - }, - selectPage: function (page) { - this.page = page - } - }, - computed: { - labels () { - return { - searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by title, artist, domain…') - } - }, - actionFilters () { - var currentFilters = { - q: this.search - } - if (this.filters) { - return _.merge(currentFilters, this.filters) - } else { - return currentFilters - } - }, - actions () { - let msg = this.$pgettext('Content/Library/Dropdown/Verb', 'Delete') - return [ - { - name: 'delete', - label: msg, - isDangerous: true - } - ] - } - }, - watch: { - search (newValue) { - this.page = 1 - this.fetchData() - }, - page () { - this.fetchData() - }, - ordering () { - this.fetchData() - }, - orderingDirection () { - this.fetchData() - } - } -} -</script> diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue index 39a876546686935b31688db2e9a5083afe1c8c50..5dcd4782504b7513fbced2b7c2daf5769c0db23c 100644 --- a/front/src/components/moderation/FilterModal.vue +++ b/front/src/components/moderation/FilterModal.vue @@ -94,7 +94,6 @@ export default { date: new Date() }) }, error => { - console.log('error', error) logger.default.error(`Error while hiding ${self.type} ${self.target.id}`) self.errors = error.backendErrors self.isLoading = false diff --git a/front/src/edits.js b/front/src/edits.js new file mode 100644 index 0000000000000000000000000000000000000000..a57680bac533aab95c3fd49815e12c69ecf13949 --- /dev/null +++ b/front/src/edits.js @@ -0,0 +1,81 @@ +export default { + getConfigs () { + return { + track: { + fields: [ + { + id: 'title', + type: 'text', + required: true, + label: this.$pgettext('*/*/*/Short, Noun', 'Title'), + getValue: (obj) => { return obj.title } + }, + { + id: 'license', + type: 'text', + required: false, + label: this.$pgettext('*/*/*/Short, Noun', 'License'), + getValue: (obj) => { return obj.license } + }, + { + id: 'position', + type: 'text', + inputType: 'number', + required: false, + label: this.$pgettext('*/*/*/Short, Noun', 'Position'), + getValue: (obj) => { return obj.position } + } + ] + } + } + }, + + getConfig () { + return this.configs[this.objectType] + }, + + getCurrentState () { + let self = this + let s = {} + this.config.fields.forEach(f => { + s[f.id] = {value: f.getValue(self.object)} + }) + return s + }, + getCurrentStateForObj (obj, config) { + let s = {} + config.fields.forEach(f => { + s[f.id] = {value: f.getValue(obj)} + }) + return s + }, + + getCanDelete () { + if (this.obj.is_applied || this.obj.is_approved) { + return false + } + if (!this.$store.state.auth.authenticated) { + return false + } + return ( + this.obj.created_by.full_username === this.$store.state.auth.fullUsername + || this.$store.state.auth.availablePermissions['library'] + ) + }, + getCanApprove () { + if (this.obj.is_applied) { + return false + } + if (!this.$store.state.auth.authenticated) { + return false + } + return this.$store.state.auth.availablePermissions['library'] + }, + getCanEdit () { + if (!this.$store.state.auth.authenticated) { + return false + } + return this.$store.state.auth.availablePermissions['library'] + }, + +} diff --git a/front/src/router/index.js b/front/src/router/index.js index e6e5b2870e282c4a980e8566637a03c90711c2d5..9320b74b81d4b69cb844b09326995b68cf6f401c 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -17,7 +17,10 @@ import LibraryArtist from '@/components/library/Artist' import LibraryArtists from '@/components/library/Artists' import LibraryAlbums from '@/components/library/Albums' import LibraryAlbum from '@/components/library/Album' -import LibraryTrack from '@/components/library/Track' +import LibraryTrackDetail from '@/components/library/TrackDetail' +import LibraryTrackEdit from '@/components/library/TrackEdit' +import EditDetail from '@/components/library/EditDetail' +import LibraryTrackDetailBase from '@/components/library/TrackBase' import LibraryRadios from '@/components/library/Radios' import RadioBuilder from '@/components/library/radios/Builder' import RadioDetail from '@/views/radios/Detail' @@ -26,7 +29,7 @@ import PlaylistList from '@/views/playlists/List' import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' -import AdminLibraryFilesList from '@/views/admin/library/FilesList' +import AdminLibraryEditsList from '@/views/admin/library/EditsList' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' @@ -206,9 +209,14 @@ export default new Router({ component: AdminLibraryBase, children: [ { - path: 'files', - name: 'manage.library.files', - component: AdminLibraryFilesList + path: 'edits', + name: 'manage.library.edits', + component: AdminLibraryEditsList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } } ] }, @@ -324,7 +332,29 @@ export default new Router({ }, { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, - { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, + { + path: 'tracks/:id', + component: LibraryTrackDetailBase, + props: true, + children: [ + { + path: '', + name: 'library.tracks.detail', + component: LibraryTrackDetail + }, + { + path: 'edit', + name: 'library.tracks.edit', + component: LibraryTrackEdit + }, + { + path: 'edit/:editId', + name: 'library.tracks.edit.detail', + component: EditDetail, + props: true, + } + ] + }, ] }, { path: '*', component: PageNotFound } diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 8893bcb495d1645584ad04da06d72091b6de2e6a..29c4c5d719da539922c36f71a3996b2101ed180b 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -8,6 +8,7 @@ export default { state: { authenticated: false, username: '', + fullUsername: '', availablePermissions: { settings: false, library: false, @@ -27,6 +28,7 @@ export default { state.authenticated = false state.profile = null state.username = '' + state.fullUsername = '' state.token = '' state.tokenData = {} state.availablePermissions = { @@ -43,6 +45,7 @@ export default { state.authenticated = value if (value === false) { state.username = null + state.fullUsername = null state.token = null state.tokenData = null state.profile = null @@ -52,6 +55,9 @@ export default { username: (state, value) => { state.username = value }, + fullUsername: (state, value) => { + state.fullUsername = value + }, avatar: (state, value) => { if (state.profile) { state.profile.avatar = value @@ -124,6 +130,7 @@ export default { resolve(response.data) }) dispatch('ui/fetchUnreadNotifications', null, { root: true }) + dispatch('ui/fetchPendingReviewEdits', null, { root: true }) dispatch('favorites/fetch', null, { root: true }) dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true }) @@ -138,6 +145,7 @@ export default { commit("authenticated", true) commit("profile", data) commit("username", data.username) + commit("fullUsername", data.full_username) Object.keys(data.permissions).forEach(function(key) { // this makes it easier to check for permissions in templates commit("permission", { diff --git a/front/src/store/ui.js b/front/src/store/ui.js index fa4624c700c73b0581d9f2d881e70f81096a2b57..cec9ef9c568b39459495bc73c4c23eb46c449500 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -12,10 +12,13 @@ export default { messages: [], notifications: { inbox: 0, + pendingReviewEdits: 0, }, websocketEventsHandlers: { 'inbox.item_added': {}, 'import.status_updated': {}, + 'mutation.created': {}, + 'mutation.updated': {}, } }, mutations: { @@ -44,8 +47,12 @@ export default { notifications (state, {type, count}) { state.notifications[type] = count }, - incrementNotifications (state, {type, count}) { - state.notifications[type] = Math.max(0, state.notifications[type] + count) + incrementNotifications (state, {type, count, value}) { + if (value != undefined) { + state.notifications[type] = Math.max(0, value) + } else { + state.notifications[type] = Math.max(0, state.notifications[type] + count) + } } }, actions: { @@ -54,6 +61,11 @@ export default { commit('notifications', {type: 'inbox', count: response.data.count}) }) }, + fetchPendingReviewEdits ({commit, rootState}, payload) { + axios.get('mutations/', {params: {is_approved: 'null', page_size: 1}}).then((response) => { + commit('notifications', {type: 'pendingReviewEdits', count: response.data.count}) + }) + }, websocketEvent ({state}, event) { let handlers = state.websocketEventsHandlers[event.type] console.log('Dispatching websocket event', event, handlers) diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 152881e303447df70732b339a92a4e499b1bb0d3..55e23f320c82cd2205d8abcaef4f900252a172f0 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -276,3 +276,12 @@ canvas.color-thief { .ui.dropdown .item[disabled] { display: none; } + +span.diff.added { + background-color:rgba(0, 255, 0, 0.25); +} + + +span.diff.removed { + background-color: rgba(255, 0, 0, 0.25); +} diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 072225a5e13bbb5af4d89eaca9e5593f63f047d4..293e569ef08906dd9c85c2916baa1f6065537b8a 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -3,7 +3,7 @@ <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> <router-link class="ui item" - :to="{name: 'manage.library.files'}"><translate :translate-context="'Menu/Admin/Link'">Files</translate></router-link> + :to="{name: 'manage.library.edits'}"><translate :translate-context="'Menu/Admin/Link'">Edits</translate></router-link> </nav> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/EditsList.vue b/front/src/views/admin/library/EditsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b38732f4ae8e57e9a2196037ae5b19c700f65d07 --- /dev/null +++ b/front/src/views/admin/library/EditsList.vue @@ -0,0 +1,33 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <edits-card-list :update-url="true" :default-query="defaultQuery"> + <h2 class="ui header"><translate :translate-context="'Content/Admin/Title/Noun'">Library edits</translate></h2> + </edits-card-list> + </section> + </main> +</template> + +<script> +import EditsCardList from "@/components/manage/library/EditsCardList" + +export default { + props: { + defaultQuery: {type: String, required: false}, + }, + components: { + EditsCardList + }, + computed: { + labels() { + return { + title: this.$pgettext('Head/Admin/Title/Noun', 'Edits') + } + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/library/FilesList.vue b/front/src/views/admin/library/FilesList.vue deleted file mode 100644 index e23745912635114916e003ff397d0f1dbb954d70..0000000000000000000000000000000000000000 --- a/front/src/views/admin/library/FilesList.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> - <main v-title="labels.title"> - <section class="ui vertical stripe segment"> - <h2 class="ui header"><translate :translate-context="'Content/Admin/Title'">Library files</translate></h2> - <div class="ui hidden divider"></div> - <library-files-table :show-library="true"></library-files-table> - </section> - </main> -</template> - -<script> -import LibraryFilesTable from "@/components/manage/library/FilesTable" - -export default { - components: { - LibraryFilesTable - }, - computed: { - labels() { - return { - title: this.$pgettext('Head/Admin/Title', 'Files') - } - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> -</style> diff --git a/front/yarn.lock b/front/yarn.lock index db05be080c2249c397b35053a871a58e6162e283..5d20f4ac1bea08bd5ab6eb8cbd1fe27de24d78b4 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -2960,6 +2960,11 @@ diff@3.5.0, diff@^3.5.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== +diff@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff" + integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"