Merge branch '689-mutations' into 'develop'
[EPIC] Audio metadata update - UI / API See merge request funkwhale/funkwhale!621
This commit is contained in:
commit
8e4320d14a
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
||||
|
|
|
@ -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"]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
|
@ -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
|
|
@ -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()
|
|
@ -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 []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
)
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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"]
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
)
|
|
@ -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]]
|
|
@ -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})
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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",
|
||||
[
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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']
|
||||
},
|
||||
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue