[EPIC] Audio metadata update - UI / API
This commit is contained in:
parent
1a1c62ab37
commit
e0c5ffcb16
|
@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
|
||||||
from rest_framework_jwt import views as jwt_views
|
from rest_framework_jwt import views as jwt_views
|
||||||
|
|
||||||
from funkwhale_api.activity import views as activity_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.music import views
|
||||||
from funkwhale_api.playlists import views as playlists_views
|
from funkwhale_api.playlists import views as playlists_views
|
||||||
from funkwhale_api.subsonic.views import SubsonicViewSet
|
from funkwhale_api.subsonic.views import SubsonicViewSet
|
||||||
|
@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
|
||||||
router.register(
|
router.register(
|
||||||
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
|
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
|
||||||
)
|
)
|
||||||
|
router.register(r"mutations", common_views.MutationViewSet, "mutations")
|
||||||
v1_patterns = router.urls
|
v1_patterns = router.urls
|
||||||
|
|
||||||
subsonic_router = routers.SimpleRouter(trailing_slash=False)
|
subsonic_router = routers.SimpleRouter(trailing_slash=False)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import django
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||||
|
|
||||||
|
import django # noqa
|
||||||
|
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from .routing import application # noqa
|
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:
|
if env_file:
|
||||||
# we have an explicitely specified env file
|
# we have an explicitely specified env file
|
||||||
# so we try to load and it fail loudly if it does not exist
|
# so we try to load and it fail loudly if it does not exist
|
||||||
print("ENV_FILE", env_file)
|
|
||||||
env.read_env(env_file)
|
env.read_env(env_file)
|
||||||
else:
|
else:
|
||||||
# we try to load from .env and config/.env
|
# we try to load from .env and config/.env
|
||||||
|
@ -150,7 +149,7 @@ if RAVEN_ENABLED:
|
||||||
|
|
||||||
# Apps specific for this project go here.
|
# Apps specific for this project go here.
|
||||||
LOCAL_APPS = (
|
LOCAL_APPS = (
|
||||||
"funkwhale_api.common",
|
"funkwhale_api.common.apps.CommonConfig",
|
||||||
"funkwhale_api.activity.apps.ActivityConfig",
|
"funkwhale_api.activity.apps.ActivityConfig",
|
||||||
"funkwhale_api.users", # custom users app
|
"funkwhale_api.users", # custom users app
|
||||||
# Your stuff: custom apps go here
|
# 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
|
# 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]
|
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.contrib.admin import register as initial_register, site, ModelAdmin # noqa
|
||||||
from django.db.models.fields.related import RelatedField
|
from django.db.models.fields.related import RelatedField
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import tasks
|
||||||
|
|
||||||
|
|
||||||
def register(model):
|
def register(model):
|
||||||
"""
|
"""
|
||||||
|
@ -17,3 +20,28 @@ def register(model):
|
||||||
return initial_register(model)(modeladmin)
|
return initial_register(model)(modeladmin)
|
||||||
|
|
||||||
return decorator
|
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 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):
|
def action_route(serializer_class):
|
||||||
|
@ -12,3 +24,67 @@ def action_route(serializer_class):
|
||||||
return response.Response(result, status=200)
|
return response.Response(result, status=200)
|
||||||
|
|
||||||
return action
|
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
|
import django_filters
|
||||||
|
from django import forms
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from . import search
|
from . import search
|
||||||
|
@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter):
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
if not value:
|
if not value:
|
||||||
return qs
|
return qs
|
||||||
cleaned = self.config.clean(value)
|
try:
|
||||||
|
cleaned = self.config.clean(value)
|
||||||
|
except forms.ValidationError:
|
||||||
|
return qs.none()
|
||||||
return search.apply(qs, cleaned)
|
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
|
return
|
||||||
|
|
||||||
matching = [t for t in tokens if t["key"] in self.filter_fields]
|
matching = [t for t in tokens if t["key"] in self.filter_fields]
|
||||||
queries = [
|
queries = [self.get_filter_query(token) for token in matching]
|
||||||
Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching
|
|
||||||
]
|
|
||||||
query = None
|
query = None
|
||||||
for q in queries:
|
for q in queries:
|
||||||
if not query:
|
if not query:
|
||||||
|
@ -114,6 +112,26 @@ class SearchConfig:
|
||||||
query = query & q
|
query = query & q
|
||||||
return query
|
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):
|
def clean_types(self, tokens):
|
||||||
if not self.types:
|
if not self.types:
|
||||||
return []
|
return []
|
||||||
|
|
|
@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class RelatedField(serializers.RelatedField):
|
class RelatedField(serializers.RelatedField):
|
||||||
default_error_messages = {
|
default_error_messages = {
|
||||||
|
@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField):
|
||||||
return SimpleUploadedFile(
|
return SimpleUploadedFile(
|
||||||
file_obj.name, content, content_type=file_obj.content_type
|
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:
|
class SkipFilterForGetObject:
|
||||||
def get_object(self, *args, **kwargs):
|
def get_object(self, *args, **kwargs):
|
||||||
setattr(self.request, "_skip_filters", True)
|
setattr(self.request, "_skip_filters", True)
|
||||||
|
@ -7,3 +25,98 @@ class SkipFilterForGetObject:
|
||||||
if getattr(self.request, "_skip_filters", False):
|
if getattr(self.request, "_skip_filters", False):
|
||||||
return queryset
|
return queryset
|
||||||
return super().filter_queryset(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/shared", views.SharedViewSet, "shared")
|
||||||
router.register(r"federation/actors", views.ActorViewSet, "actors")
|
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")
|
router.register(r".well-known", views.WellKnownViewSet, "well-known")
|
||||||
|
|
||||||
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
|
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
|
||||||
|
|
|
@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
|
||||||
return response.Response({})
|
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):
|
class WellKnownViewSet(viewsets.GenericViewSet):
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
from funkwhale_api.common import fields
|
from funkwhale_api.common import fields
|
||||||
|
from funkwhale_api.common import filters as common_filters
|
||||||
from funkwhale_api.common import search
|
from funkwhale_api.common import search
|
||||||
from funkwhale_api.moderation import filters as moderation_filters
|
from funkwhale_api.moderation import filters as moderation_filters
|
||||||
|
|
||||||
|
@ -28,12 +29,14 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet):
|
||||||
class TrackFilter(moderation_filters.HiddenContentFilterSet):
|
class TrackFilter(moderation_filters.HiddenContentFilterSet):
|
||||||
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
|
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
|
||||||
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
|
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
|
||||||
|
id = common_filters.MultipleQueryFilter(coerce=int)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Track
|
model = models.Track
|
||||||
fields = {
|
fields = {
|
||||||
"title": ["exact", "iexact", "startswith", "icontains"],
|
"title": ["exact", "iexact", "startswith", "icontains"],
|
||||||
"playable": ["exact"],
|
"playable": ["exact"],
|
||||||
|
"id": ["exact"],
|
||||||
"artist": ["exact"],
|
"artist": ["exact"],
|
||||||
"album": ["exact"],
|
"album": ["exact"],
|
||||||
"license": ["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 rest_framework.response import Response
|
||||||
from taggit.models import Tag
|
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 permissions as common_permissions
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
|
@ -186,6 +187,8 @@ class TrackViewSet(
|
||||||
"artist__name",
|
"artist__name",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mutations = common_decorators.mutations_route(types=["update"])
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
filter_favorites = self.request.GET.get("favorites", None)
|
filter_favorites = self.request.GET.get("favorites", None)
|
||||||
|
|
|
@ -94,6 +94,7 @@ class UserWriteSerializer(serializers.ModelSerializer):
|
||||||
class UserReadSerializer(serializers.ModelSerializer):
|
class UserReadSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField()
|
permissions = serializers.SerializerMethodField()
|
||||||
|
full_username = serializers.SerializerMethodField()
|
||||||
avatar = avatar_field
|
avatar = avatar_field
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -101,6 +102,7 @@ class UserReadSerializer(serializers.ModelSerializer):
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
|
"full_username",
|
||||||
"name",
|
"name",
|
||||||
"email",
|
"email",
|
||||||
"is_staff",
|
"is_staff",
|
||||||
|
@ -114,6 +116,10 @@ class UserReadSerializer(serializers.ModelSerializer):
|
||||||
def get_permissions(self, o):
|
def get_permissions(self, o):
|
||||||
return o.get_permissions()
|
return o.get_permissions()
|
||||||
|
|
||||||
|
def get_full_username(self, o):
|
||||||
|
if o.actor:
|
||||||
|
return o.actor.full_username
|
||||||
|
|
||||||
|
|
||||||
class MeSerializer(UserReadSerializer):
|
class MeSerializer(UserReadSerializer):
|
||||||
quota_status = serializers.SerializerMethodField()
|
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
|
import pytest
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django import forms
|
||||||
|
|
||||||
from funkwhale_api.common import search
|
from funkwhale_api.common import search
|
||||||
from funkwhale_api.music import models as music_models
|
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
|
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(
|
@pytest.mark.parametrize(
|
||||||
"query,expected",
|
"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
|
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")])
|
@pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")])
|
||||||
def test_artist_view_filter_playable(param, expected, factories, api_request):
|
def test_artist_view_filter_playable(param, expected, factories, api_request):
|
||||||
artists = {
|
artists = {
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
"dateformat": "^3.0.3",
|
"dateformat": "^3.0.3",
|
||||||
|
"diff": "^4.0.1",
|
||||||
"django-channels": "^1.1.6",
|
"django-channels": "^1.1.6",
|
||||||
"howler": "^2.0.14",
|
"howler": "^2.0.14",
|
||||||
"js-logger": "^1.4.1",
|
"js-logger": "^1.4.1",
|
||||||
|
|
|
@ -92,6 +92,16 @@ export default {
|
||||||
id: 'sidebarCount',
|
id: 'sidebarCount',
|
||||||
handler: this.incrementNotificationCountInSidebar
|
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 () {
|
mounted () {
|
||||||
let self = this
|
let self = this
|
||||||
|
@ -110,12 +120,23 @@ export default {
|
||||||
eventName: 'inbox.item_added',
|
eventName: 'inbox.item_added',
|
||||||
id: 'sidebarCount',
|
id: 'sidebarCount',
|
||||||
})
|
})
|
||||||
|
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||||
|
eventName: 'mutation.created',
|
||||||
|
id: 'sidebarReviewEditCount',
|
||||||
|
})
|
||||||
|
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||||
|
eventName: 'mutation.updated',
|
||||||
|
id: 'sidebarReviewEditCount',
|
||||||
|
})
|
||||||
this.disconnect()
|
this.disconnect()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
incrementNotificationCountInSidebar (event) {
|
incrementNotificationCountInSidebar (event) {
|
||||||
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
|
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
|
||||||
},
|
},
|
||||||
|
incrementReviewEditCountInSidebar (event) {
|
||||||
|
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count})
|
||||||
|
},
|
||||||
fetchNodeInfo () {
|
fetchNodeInfo () {
|
||||||
let self = this
|
let self = this
|
||||||
axios.get('instance/nodeinfo/2.0/').then(response => {
|
axios.get('instance/nodeinfo/2.0/').then(response => {
|
||||||
|
@ -179,7 +200,6 @@ export default {
|
||||||
}),
|
}),
|
||||||
suggestedInstances () {
|
suggestedInstances () {
|
||||||
let instances = this.$store.state.instance.knownInstances.slice(0)
|
let instances = this.$store.state.instance.knownInstances.slice(0)
|
||||||
console.log('instance', instances)
|
|
||||||
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
|
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
|
||||||
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
|
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
|
||||||
if (!serverUrl.endsWith('/')) {
|
if (!serverUrl.endsWith('/')) {
|
||||||
|
@ -188,7 +208,6 @@ export default {
|
||||||
instances.push(serverUrl)
|
instances.push(serverUrl)
|
||||||
}
|
}
|
||||||
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
|
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
|
||||||
console.log('HELLO', instances)
|
|
||||||
return _.uniq(instances.filter((e) => {return e}))
|
return _.uniq(instances.filter((e) => {return e}))
|
||||||
},
|
},
|
||||||
version () {
|
version () {
|
||||||
|
|
|
@ -97,6 +97,17 @@
|
||||||
:to="{name: 'manage.moderation.domains.list'}">
|
:to="{name: 'manage.moderation.domains.list'}">
|
||||||
<i class="shield icon"></i><translate>Moderation</translate>
|
<i class="shield icon"></i><translate>Moderation</translate>
|
||||||
</router-link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -210,10 +221,12 @@ export default {
|
||||||
let mainMenu = this.$gettext("Main menu")
|
let mainMenu = this.$gettext("Main menu")
|
||||||
let selectTrack = this.$gettext("Play this track")
|
let selectTrack = this.$gettext("Play this track")
|
||||||
let pendingFollows = this.$gettext("Pending follow requests")
|
let pendingFollows = this.$gettext("Pending follow requests")
|
||||||
|
let pendingReviewEdits = this.$gettext("Pending review edits")
|
||||||
return {
|
return {
|
||||||
pendingFollows,
|
pendingFollows,
|
||||||
mainMenu,
|
mainMenu,
|
||||||
selectTrack
|
selectTrack,
|
||||||
|
pendingReviewEdits
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tracks: {
|
tracks: {
|
||||||
|
|
|
@ -97,7 +97,6 @@ export default {
|
||||||
username: this.credentials.username,
|
username: this.credentials.username,
|
||||||
password: this.credentials.password
|
password: this.credentials.password
|
||||||
}
|
}
|
||||||
console.log('NEXT', this.next)
|
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch("auth/login", {
|
.dispatch("auth/login", {
|
||||||
credentials,
|
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>
|
<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>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import {mapState} from 'vuex'
|
import {mapState} from 'vuex'
|
||||||
export default {
|
export default {
|
||||||
props: ['date'],
|
props: {
|
||||||
|
date: {required: true},
|
||||||
|
icon: {type: Boolean, required: false, default: false},
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
lastDate: state => state.ui.lastDate
|
lastDate: state => state.ui.lastDate
|
||||||
|
|
|
@ -44,5 +44,8 @@ import Tooltip from '@/components/common/Tooltip'
|
||||||
|
|
||||||
Vue.component('tooltip', Tooltip)
|
Vue.component('tooltip', Tooltip)
|
||||||
|
|
||||||
|
import EmptyState from '@/components/common/EmptyState'
|
||||||
|
|
||||||
|
Vue.component('empty-state', EmptyState)
|
||||||
|
|
||||||
export default {}
|
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>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="ui vertical stripe center aligned segment">
|
<router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view>
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
@ -169,7 +85,6 @@ import logger from "@/logging"
|
||||||
import PlayButton from "@/components/audio/PlayButton"
|
import PlayButton from "@/components/audio/PlayButton"
|
||||||
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
||||||
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
||||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
|
||||||
import Modal from '@/components/semantic/Modal'
|
import Modal from '@/components/semantic/Modal'
|
||||||
import EmbedWizard from "@/components/audio/EmbedWizard"
|
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||||
|
|
||||||
|
@ -181,7 +96,6 @@ export default {
|
||||||
PlayButton,
|
PlayButton,
|
||||||
TrackPlaylistIcon,
|
TrackPlaylistIcon,
|
||||||
TrackFavoriteIcon,
|
TrackFavoriteIcon,
|
||||||
LibraryWidget,
|
|
||||||
Modal,
|
Modal,
|
||||||
EmbedWizard
|
EmbedWizard
|
||||||
},
|
},
|
||||||
|
@ -189,17 +103,13 @@ export default {
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
isLoadingTrack: true,
|
isLoadingTrack: true,
|
||||||
isLoadingLyrics: true,
|
|
||||||
track: null,
|
track: null,
|
||||||
lyrics: null,
|
showEmbedModal: false,
|
||||||
licenseData: null,
|
libraries: []
|
||||||
libraries: [],
|
|
||||||
showEmbedModal: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
this.fetchLyrics()
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
fetchData() {
|
fetchData() {
|
||||||
|
@ -212,29 +122,6 @@ export default {
|
||||||
self.isLoadingTrack = false
|
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: {
|
computed: {
|
||||||
publicLibraries () {
|
publicLibraries () {
|
||||||
|
@ -242,16 +129,16 @@ export default {
|
||||||
return l.privacy_level === 'everyone'
|
return l.privacy_level === 'everyone'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
labels() {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('Head/Track/Title', "Track")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
upload() {
|
upload() {
|
||||||
if (this.track.uploads) {
|
if (this.track.uploads) {
|
||||||
return this.track.uploads[0]
|
return this.track.uploads[0]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
labels() {
|
||||||
|
return {
|
||||||
|
title: this.$pgettext('Head/Track/Title', "Track")
|
||||||
|
}
|
||||||
|
},
|
||||||
wikipediaUrl() {
|
wikipediaUrl() {
|
||||||
return (
|
return (
|
||||||
"https://en.wikipedia.org/w/index.php?search=" +
|
"https://en.wikipedia.org/w/index.php?search=" +
|
||||||
|
@ -276,11 +163,6 @@ export default {
|
||||||
}
|
}
|
||||||
return u
|
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() {
|
cover() {
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
|
@ -302,30 +184,11 @@ export default {
|
||||||
")"
|
")"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
license() {
|
|
||||||
if (!this.track || !this.track.license) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return this.licenseData
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
id() {
|
id() {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
},
|
},
|
||||||
track (v) {
|
|
||||||
if (v && v.license) {
|
|
||||||
this.fetchLicenseData(v.license)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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()
|
date: new Date()
|
||||||
})
|
})
|
||||||
}, error => {
|
}, error => {
|
||||||
console.log('error', error)
|
|
||||||
logger.default.error(`Error while hiding ${self.type} ${self.target.id}`)
|
logger.default.error(`Error while hiding ${self.type} ${self.target.id}`)
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
self.isLoading = false
|
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 LibraryArtists from '@/components/library/Artists'
|
||||||
import LibraryAlbums from '@/components/library/Albums'
|
import LibraryAlbums from '@/components/library/Albums'
|
||||||
import LibraryAlbum from '@/components/library/Album'
|
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 LibraryRadios from '@/components/library/Radios'
|
||||||
import RadioBuilder from '@/components/library/radios/Builder'
|
import RadioBuilder from '@/components/library/radios/Builder'
|
||||||
import RadioDetail from '@/views/radios/Detail'
|
import RadioDetail from '@/views/radios/Detail'
|
||||||
|
@ -26,7 +29,7 @@ import PlaylistList from '@/views/playlists/List'
|
||||||
import Favorites from '@/components/favorites/List'
|
import Favorites from '@/components/favorites/List'
|
||||||
import AdminSettings from '@/views/admin/Settings'
|
import AdminSettings from '@/views/admin/Settings'
|
||||||
import AdminLibraryBase from '@/views/admin/library/Base'
|
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 AdminUsersBase from '@/views/admin/users/Base'
|
||||||
import AdminUsersList from '@/views/admin/users/UsersList'
|
import AdminUsersList from '@/views/admin/users/UsersList'
|
||||||
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
|
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
|
||||||
|
@ -206,9 +209,14 @@ export default new Router({
|
||||||
component: AdminLibraryBase,
|
component: AdminLibraryBase,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'files',
|
path: 'edits',
|
||||||
name: 'manage.library.files',
|
name: 'manage.library.edits',
|
||||||
component: AdminLibraryFilesList
|
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: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
|
||||||
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, 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 }
|
{ path: '*', component: PageNotFound }
|
||||||
|
|
|
@ -8,6 +8,7 @@ export default {
|
||||||
state: {
|
state: {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
username: '',
|
username: '',
|
||||||
|
fullUsername: '',
|
||||||
availablePermissions: {
|
availablePermissions: {
|
||||||
settings: false,
|
settings: false,
|
||||||
library: false,
|
library: false,
|
||||||
|
@ -27,6 +28,7 @@ export default {
|
||||||
state.authenticated = false
|
state.authenticated = false
|
||||||
state.profile = null
|
state.profile = null
|
||||||
state.username = ''
|
state.username = ''
|
||||||
|
state.fullUsername = ''
|
||||||
state.token = ''
|
state.token = ''
|
||||||
state.tokenData = {}
|
state.tokenData = {}
|
||||||
state.availablePermissions = {
|
state.availablePermissions = {
|
||||||
|
@ -43,6 +45,7 @@ export default {
|
||||||
state.authenticated = value
|
state.authenticated = value
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
state.username = null
|
state.username = null
|
||||||
|
state.fullUsername = null
|
||||||
state.token = null
|
state.token = null
|
||||||
state.tokenData = null
|
state.tokenData = null
|
||||||
state.profile = null
|
state.profile = null
|
||||||
|
@ -52,6 +55,9 @@ export default {
|
||||||
username: (state, value) => {
|
username: (state, value) => {
|
||||||
state.username = value
|
state.username = value
|
||||||
},
|
},
|
||||||
|
fullUsername: (state, value) => {
|
||||||
|
state.fullUsername = value
|
||||||
|
},
|
||||||
avatar: (state, value) => {
|
avatar: (state, value) => {
|
||||||
if (state.profile) {
|
if (state.profile) {
|
||||||
state.profile.avatar = value
|
state.profile.avatar = value
|
||||||
|
@ -124,6 +130,7 @@ export default {
|
||||||
resolve(response.data)
|
resolve(response.data)
|
||||||
})
|
})
|
||||||
dispatch('ui/fetchUnreadNotifications', null, { root: true })
|
dispatch('ui/fetchUnreadNotifications', null, { root: true })
|
||||||
|
dispatch('ui/fetchPendingReviewEdits', null, { root: true })
|
||||||
dispatch('favorites/fetch', null, { root: true })
|
dispatch('favorites/fetch', null, { root: true })
|
||||||
dispatch('moderation/fetchContentFilters', null, { root: true })
|
dispatch('moderation/fetchContentFilters', null, { root: true })
|
||||||
dispatch('playlists/fetchOwn', null, { root: true })
|
dispatch('playlists/fetchOwn', null, { root: true })
|
||||||
|
@ -138,6 +145,7 @@ export default {
|
||||||
commit("authenticated", true)
|
commit("authenticated", true)
|
||||||
commit("profile", data)
|
commit("profile", data)
|
||||||
commit("username", data.username)
|
commit("username", data.username)
|
||||||
|
commit("fullUsername", data.full_username)
|
||||||
Object.keys(data.permissions).forEach(function(key) {
|
Object.keys(data.permissions).forEach(function(key) {
|
||||||
// this makes it easier to check for permissions in templates
|
// this makes it easier to check for permissions in templates
|
||||||
commit("permission", {
|
commit("permission", {
|
||||||
|
|
|
@ -12,10 +12,13 @@ export default {
|
||||||
messages: [],
|
messages: [],
|
||||||
notifications: {
|
notifications: {
|
||||||
inbox: 0,
|
inbox: 0,
|
||||||
|
pendingReviewEdits: 0,
|
||||||
},
|
},
|
||||||
websocketEventsHandlers: {
|
websocketEventsHandlers: {
|
||||||
'inbox.item_added': {},
|
'inbox.item_added': {},
|
||||||
'import.status_updated': {},
|
'import.status_updated': {},
|
||||||
|
'mutation.created': {},
|
||||||
|
'mutation.updated': {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
@ -44,8 +47,12 @@ export default {
|
||||||
notifications (state, {type, count}) {
|
notifications (state, {type, count}) {
|
||||||
state.notifications[type] = count
|
state.notifications[type] = count
|
||||||
},
|
},
|
||||||
incrementNotifications (state, {type, count}) {
|
incrementNotifications (state, {type, count, value}) {
|
||||||
state.notifications[type] = Math.max(0, state.notifications[type] + count)
|
if (value != undefined) {
|
||||||
|
state.notifications[type] = Math.max(0, value)
|
||||||
|
} else {
|
||||||
|
state.notifications[type] = Math.max(0, state.notifications[type] + count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
@ -54,6 +61,11 @@ export default {
|
||||||
commit('notifications', {type: 'inbox', count: response.data.count})
|
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) {
|
websocketEvent ({state}, event) {
|
||||||
let handlers = state.websocketEventsHandlers[event.type]
|
let handlers = state.websocketEventsHandlers[event.type]
|
||||||
console.log('Dispatching websocket event', event, handlers)
|
console.log('Dispatching websocket event', event, handlers)
|
||||||
|
|
|
@ -276,3 +276,12 @@ canvas.color-thief {
|
||||||
.ui.dropdown .item[disabled] {
|
.ui.dropdown .item[disabled] {
|
||||||
display: none;
|
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">
|
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
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>
|
</nav>
|
||||||
<router-view :key="$route.fullPath"></router-view>
|
<router-view :key="$route.fullPath"></router-view>
|
||||||
</div>
|
</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"
|
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
|
||||||
integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
|
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:
|
diffie-hellman@^5.0.0:
|
||||||
version "5.0.3"
|
version "5.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
|
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
|
||||||
|
|
Loading…
Reference in New Issue