Merge branch '689-mutations' into 'develop'

[EPIC] Audio metadata update - UI / API

See merge request funkwhale/funkwhale!621
This commit is contained in:
Eliot Berriot 2019-02-28 09:31:04 +01:00
commit 8e4320d14a
59 changed files with 2793 additions and 436 deletions

View File

@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
from rest_framework_jwt import views as jwt_views
from funkwhale_api.activity import views as activity_views
from funkwhale_api.common import views as common_views
from funkwhale_api.music import views
from funkwhale_api.playlists import views as playlists_views
from funkwhale_api.subsonic.views import SubsonicViewSet
@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register(
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
)
router.register(r"mutations", common_views.MutationViewSet, "mutations")
v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False)

View File

@ -1,9 +1,9 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
import django # noqa
django.setup()
from .routing import application # noqa
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")

View File

@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None)
if env_file:
# we have an explicitely specified env file
# so we try to load and it fail loudly if it does not exist
print("ENV_FILE", env_file)
env.read_env(env_file)
else:
# we try to load from .env and config/.env
@ -150,7 +149,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
"funkwhale_api.common",
"funkwhale_api.common.apps.CommonConfig",
"funkwhale_api.activity.apps.ActivityConfig",
"funkwhale_api.users", # custom users app
# Your stuff: custom apps go here

View File

@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False
# Your local stuff: Below this line define 3rd party library settings
LOGGING = {
"version": 1,
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
"loggers": {
"django.request": {
"handlers": ["console"],
"propagate": True,
"level": "DEBUG",
},
"django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
"": {"level": "DEBUG", "handlers": ["console"]},
},
}
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]

View File

@ -1,6 +1,9 @@
from django.contrib.admin import register as initial_register, site, ModelAdmin # noqa
from django.db.models.fields.related import RelatedField
from . import models
from . import tasks
def register(model):
"""
@ -17,3 +20,28 @@ def register(model):
return initial_register(model)(modeladmin)
return decorator
def apply(modeladmin, request, queryset):
queryset.update(is_approved=True)
for id in queryset.values_list("id", flat=True):
tasks.apply_mutation.delay(mutation_id=id)
apply.short_description = "Approve and apply"
@register(models.Mutation)
class MutationAdmin(ModelAdmin):
list_display = [
"uuid",
"type",
"created_by",
"creation_date",
"applied_date",
"is_approved",
"is_applied",
]
search_fields = ["created_by__preferred_username"]
list_filter = ["type", "is_approved", "is_applied"]
actions = [apply]

View File

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

View File

@ -1,5 +1,17 @@
from rest_framework import response
from django.db import transaction
from rest_framework import decorators
from rest_framework import exceptions
from rest_framework import response
from rest_framework import status
from . import filters
from . import models
from . import mutations as common_mutations
from . import serializers
from . import signals
from . import tasks
from . import utils
def action_route(serializer_class):
@ -12,3 +24,67 @@ def action_route(serializer_class):
return response.Response(result, status=200)
return action
def mutations_route(types):
"""
Given a queryset and a list of mutation types, return a view
that can be included in any viewset, and serve:
GET /{id}/mutations/ - list of mutations for the given object
POST /{id}/mutations/ - create a mutation for the given object
"""
@transaction.atomic
def mutations(self, request, *args, **kwargs):
obj = self.get_object()
if request.method == "GET":
queryset = models.Mutation.objects.get_for_target(obj).filter(
type__in=types
)
queryset = queryset.order_by("-creation_date")
filterset = filters.MutationFilter(request.GET, queryset=queryset)
page = self.paginate_queryset(filterset.qs)
if page is not None:
serializer = serializers.APIMutationSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = serializers.APIMutationSerializer(queryset, many=True)
return response.Response(serializer.data)
if request.method == "POST":
if not request.user.is_authenticated:
raise exceptions.NotAuthenticated()
serializer = serializers.APIMutationSerializer(
data=request.data, context={"registry": common_mutations.registry}
)
serializer.is_valid(raise_exception=True)
if not common_mutations.registry.has_perm(
actor=request.user.actor,
type=serializer.validated_data["type"],
obj=obj,
perm="approve"
if serializer.validated_data.get("is_approved", False)
else "suggest",
):
raise exceptions.PermissionDenied()
final_payload = common_mutations.registry.get_validated_payload(
type=serializer.validated_data["type"],
payload=serializer.validated_data["payload"],
obj=obj,
)
mutation = serializer.save(
created_by=request.user.actor,
target=obj,
payload=final_payload,
is_approved=serializer.validated_data.get("is_approved", None),
)
if mutation.is_approved:
utils.on_commit(tasks.apply_mutation.delay, mutation_id=mutation.pk)
utils.on_commit(
signals.mutation_created.send, sender=None, mutation=mutation
)
return response.Response(serializer.data, status=status.HTTP_201_CREATED)
return decorators.action(methods=["get", "post"], detail=True)(mutations)

View File

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

View File

@ -1,4 +1,5 @@
import django_filters
from django import forms
from django.db import models
from . import search
@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter):
def filter(self, qs, value):
if not value:
return qs
cleaned = self.config.clean(value)
try:
cleaned = self.config.clean(value)
except forms.ValidationError:
return qs.none()
return search.apply(qs, cleaned)

View File

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

View File

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

View File

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

View File

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

View File

@ -103,9 +103,7 @@ class SearchConfig:
return
matching = [t for t in tokens if t["key"] in self.filter_fields]
queries = [
Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching
]
queries = [self.get_filter_query(token) for token in matching]
query = None
for q in queries:
if not query:
@ -114,6 +112,26 @@ class SearchConfig:
query = query & q
return query
def get_filter_query(self, token):
raw_value = token["value"]
try:
field = self.filter_fields[token["key"]]["field"]
value = field.clean(raw_value)
except KeyError:
# no cleaning to apply
value = raw_value
try:
query_field = self.filter_fields[token["key"]]["to"]
return Q(**{query_field: value})
except KeyError:
pass
# we don't have a basic filter -> field mapping, this likely means we
# have a dynamic handler in the config
handler = self.filter_fields[token["key"]]["handler"]
value = handler(value)
return value
def clean_types(self, tokens):
if not self.types:
return []

View File

@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils.encoding import smart_text
from django.utils.translation import ugettext_lazy as _
from . import models
class RelatedField(serializers.RelatedField):
default_error_messages = {
@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField):
return SimpleUploadedFile(
file_obj.name, content, content_type=file_obj.content_type
)
from funkwhale_api.federation import serializers as federation_serializers # noqa
TARGET_ID_TYPE_MAPPING = {
"music.Track": ("id", "track"),
"music.Artist": ("id", "artist"),
"music.Album": ("id", "album"),
}
class APIMutationSerializer(serializers.ModelSerializer):
created_by = federation_serializers.APIActorSerializer(read_only=True)
target = serializers.SerializerMethodField()
class Meta:
model = models.Mutation
fields = [
"fid",
"uuid",
"type",
"creation_date",
"applied_date",
"is_approved",
"is_applied",
"created_by",
"approved_by",
"summary",
"payload",
"previous_state",
"target",
]
read_only_fields = [
"uuid",
"creation_date",
"fid",
"is_applied",
"created_by",
"approved_by",
"previous_state",
]
def get_target(self, obj):
target = obj.target
if not target:
return
id_field, type = TARGET_ID_TYPE_MAPPING[target._meta.label]
return {"type": type, "id": getattr(target, id_field), "repr": str(target)}
def validate_type(self, value):
if value not in self.context["registry"]:
raise serializers.ValidationError("Invalid mutation type {}".format(value))
return value

View File

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

View File

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

View File

@ -1,3 +1,21 @@
from django.db import transaction
from rest_framework.decorators import action
from rest_framework import exceptions
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import viewsets
from . import filters
from . import models
from . import mutations
from . import serializers
from . import signals
from . import tasks
from . import utils
class SkipFilterForGetObject:
def get_object(self, *args, **kwargs):
setattr(self.request, "_skip_filters", True)
@ -7,3 +25,98 @@ class SkipFilterForGetObject:
if getattr(self.request, "_skip_filters", False):
return queryset
return super().filter_queryset(queryset)
class MutationViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.Mutation.objects.all()
.order_by("-creation_date")
.select_related("created_by", "approved_by")
.prefetch_related("target")
)
serializer_class = serializers.APIMutationSerializer
permission_classes = [permissions.IsAuthenticated]
ordering_fields = ("creation_date",)
filterset_class = filters.MutationFilter
def perform_destroy(self, instance):
if instance.is_applied:
raise exceptions.PermissionDenied("You cannot delete an applied mutation")
actor = self.request.user.actor
is_owner = actor == instance.created_by
if not any(
[
is_owner,
mutations.registry.has_perm(
perm="approve", type=instance.type, obj=instance.target, actor=actor
),
]
):
raise exceptions.PermissionDenied()
return super().perform_destroy(instance)
@action(detail=True, methods=["post"])
@transaction.atomic
def approve(self, request, *args, **kwargs):
instance = self.get_object()
if instance.is_applied:
return response.Response(
{"error": "This mutation was already applied"}, status=403
)
actor = self.request.user.actor
can_approve = mutations.registry.has_perm(
perm="approve", type=instance.type, obj=instance.target, actor=actor
)
if not can_approve:
raise exceptions.PermissionDenied()
previous_is_approved = instance.is_approved
instance.approved_by = actor
instance.is_approved = True
instance.save(update_fields=["approved_by", "is_approved"])
utils.on_commit(tasks.apply_mutation.delay, mutation_id=instance.id)
utils.on_commit(
signals.mutation_updated.send,
sender=None,
mutation=instance,
old_is_approved=previous_is_approved,
new_is_approved=instance.is_approved,
)
return response.Response({}, status=200)
@action(detail=True, methods=["post"])
@transaction.atomic
def reject(self, request, *args, **kwargs):
instance = self.get_object()
if instance.is_applied:
return response.Response(
{"error": "This mutation was already applied"}, status=403
)
actor = self.request.user.actor
can_approve = mutations.registry.has_perm(
perm="approve", type=instance.type, obj=instance.target, actor=actor
)
if not can_approve:
raise exceptions.PermissionDenied()
previous_is_approved = instance.is_approved
instance.approved_by = actor
instance.is_approved = False
instance.save(update_fields=["approved_by", "is_approved"])
utils.on_commit(
signals.mutation_updated.send,
sender=None,
mutation=instance,
old_is_approved=previous_is_approved,
new_is_approved=instance.is_approved,
)
return response.Response({}, status=200)

View File

@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
router.register(r"federation/shared", views.SharedViewSet, "shared")
router.register(r"federation/actors", views.ActorViewSet, "actors")
router.register(r"federation/edits", views.EditViewSet, "edits")
router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")

View File

@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
return response.Response({})
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer]
# queryset = common_models.Mutation.objects.local().select_related()
# serializer_class = serializers.ActorSerializer
class WellKnownViewSet(viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []

View File

@ -1,6 +1,7 @@
from django_filters import rest_framework as filters
from funkwhale_api.common import fields
from funkwhale_api.common import filters as common_filters
from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters
@ -28,12 +29,14 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet):
class TrackFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
id = common_filters.MultipleQueryFilter(coerce=int)
class Meta:
model = models.Track
fields = {
"title": ["exact", "iexact", "startswith", "icontains"],
"playable": ["exact"],
"id": ["exact"],
"artist": ["exact"],
"album": ["exact"],
"license": ["exact"],

View File

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

View File

@ -15,6 +15,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from taggit.models import Tag
from funkwhale_api.common import decorators as common_decorators
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
@ -186,6 +187,8 @@ class TrackViewSet(
"artist__name",
)
mutations = common_decorators.mutations_route(types=["update"])
def get_queryset(self):
queryset = super().get_queryset()
filter_favorites = self.request.GET.get("favorites", None)

View File

@ -94,6 +94,7 @@ class UserWriteSerializer(serializers.ModelSerializer):
class UserReadSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
full_username = serializers.SerializerMethodField()
avatar = avatar_field
class Meta:
@ -101,6 +102,7 @@ class UserReadSerializer(serializers.ModelSerializer):
fields = [
"id",
"username",
"full_username",
"name",
"email",
"is_staff",
@ -114,6 +116,10 @@ class UserReadSerializer(serializers.ModelSerializer):
def get_permissions(self, o):
return o.get_permissions()
def get_full_username(self, o):
if o.actor:
return o.actor.full_username
class MeSerializer(UserReadSerializer):
quota_status = serializers.SerializerMethodField()

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import pytest
from django.db.models import Q
from django import forms
from funkwhale_api.common import search
from funkwhale_api.music import models as music_models
@ -45,6 +46,24 @@ def test_search_config_query(query, expected):
assert cleaned["search_query"] == expected
def test_search_config_query_filter_field_handler():
s = search.SearchConfig(
filter_fields={"account": {"handler": lambda v: Q(hello="world")}}
)
cleaned = s.clean("account:noop")
assert cleaned["filter_query"] == Q(hello="world")
def test_search_config_query_filter_field():
s = search.SearchConfig(
filter_fields={"account": {"to": "noop", "field": forms.BooleanField()}}
)
cleaned = s.clean("account:true")
assert cleaned["filter_query"] == Q(noop=True)
@pytest.mark.parametrize(
"query,expected",
[

View File

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

View File

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

View File

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

View File

@ -70,6 +70,19 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client):
assert response.data == expected
def test_track_list_filter_id(api_request, factories, logged_in_api_client):
track1 = factories["music.Track"]()
track2 = factories["music.Track"]()
factories["music.Track"]()
url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url, {"id[]": [track1.id, track2.id]})
assert response.status_code == 200
assert response.data["count"] == 2
assert response.data["results"][0]["id"] == track2.id
assert response.data["results"][1]["id"] == track1.id
@pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")])
def test_artist_view_filter_playable(param, expected, factories, api_request):
artists = {

View File

@ -13,6 +13,7 @@
"dependencies": {
"axios": "^0.18.0",
"dateformat": "^3.0.3",
"diff": "^4.0.1",
"django-channels": "^1.1.6",
"howler": "^2.0.14",
"js-logger": "^1.4.1",

View File

@ -92,6 +92,16 @@ export default {
id: 'sidebarCount',
handler: this.incrementNotificationCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
},
mounted () {
let self = this
@ -110,12 +120,23 @@ export default {
eventName: 'inbox.item_added',
id: 'sidebarCount',
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount',
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount',
})
this.disconnect()
},
methods: {
incrementNotificationCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
},
incrementReviewEditCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count})
},
fetchNodeInfo () {
let self = this
axios.get('instance/nodeinfo/2.0/').then(response => {
@ -179,7 +200,6 @@ export default {
}),
suggestedInstances () {
let instances = this.$store.state.instance.knownInstances.slice(0)
console.log('instance', instances)
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
if (!serverUrl.endsWith('/')) {
@ -188,7 +208,6 @@ export default {
instances.push(serverUrl)
}
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
console.log('HELLO', instances)
return _.uniq(instances.filter((e) => {return e}))
},
version () {

View File

@ -97,6 +97,17 @@
:to="{name: 'manage.moderation.domains.list'}">
<i class="shield icon"></i><translate>Moderation</translate>
</router-link>
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="item"
:to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}">
<i class="book icon"></i><translate>Library</translate>
<div
v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
:title="labels.pendingReviewEdits"
:class="['ui', 'teal', 'label']">
{{ $store.state.ui.notifications.pendingReviewEdits }}</div>
</router-link>
</div>
</div>
</nav>
@ -210,10 +221,12 @@ export default {
let mainMenu = this.$gettext("Main menu")
let selectTrack = this.$gettext("Play this track")
let pendingFollows = this.$gettext("Pending follow requests")
let pendingReviewEdits = this.$gettext("Pending review edits")
return {
pendingFollows,
mainMenu,
selectTrack
selectTrack,
pendingReviewEdits
}
},
tracks: {

View File

@ -97,7 +97,6 @@ export default {
username: this.credentials.username,
password: this.credentials.password
}
console.log('NEXT', this.next)
this.$store
.dispatch("auth/login", {
credentials,

View File

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

View File

@ -1,10 +1,16 @@
<template>
<time :datetime="date" :title="date | moment">{{ realDate | ago($store.state.ui.momentLocale) }}</time>
<time :datetime="date" :title="date | moment">
<i v-if="icon" class="outline clock icon"></i>
{{ realDate | ago($store.state.ui.momentLocale) }}
</time>
</template>
<script>
import {mapState} from 'vuex'
export default {
props: ['date'],
props: {
date: {required: true},
icon: {type: Boolean, required: false, default: false},
},
computed: {
...mapState({
lastDate: state => state.ui.lastDate

View File

@ -44,5 +44,8 @@ import Tooltip from '@/components/common/Tooltip'
Vue.component('tooltip', Tooltip)
import EmptyState from '@/components/common/EmptyState'
Vue.component('empty-state', EmptyState)
export default {}

View File

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

View File

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

View File

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

View File

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

View File

@ -64,99 +64,15 @@
</div>
</modal>
</template>
<router-link
:to="{name: 'library.tracks.edit', params: {id: track.id }}"
class="ui icon labeled button">
<i class="edit icon"></i>
<translate :translate-context="'Content/Track/Button.Label/Verb'">Edit</translate>
</router-link>
</div>
</section>
<section class="ui vertical stripe center aligned segment">
<h2 class="ui header">
<translate :translate-context="'Content/Track/Title/Noun'">Track information</translate>
</h2>
<table class="ui very basic collapsing celled center aligned table">
<tbody>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate>
</td>
<td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
<td v-else>
<translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate>
</td>
</tr>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate>
</td>
<td v-if="license">
<a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
</td>
<td v-else>
<translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate>
</td>
</tr>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label'">Duration</translate>
</td>
<td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
<td v-else>
<translate :translate-context="'*/*/*'">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label'">Size</translate>
</td>
<td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
<td v-else>
<translate :translate-context="'*/*/*'">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate>
</td>
<td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
<td v-else>
<translate :translate-context="'*/*/*'">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate>
</td>
<td v-if="upload && upload.extension">{{ upload.extension }}</td>
<td v-else>
<translate :translate-context="'*/*/*'">N/A</translate>
</td>
</tr>
</tbody>
</table>
</section>
<section class="ui vertical stripe center aligned segment">
<h2>
<translate :translate-context="'Content/Track/Title'">Lyrics</translate>
</h2>
<div v-if="isLoadingLyrics" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<div v-if="lyrics" v-html="lyrics.content_rendered"></div>
<template v-if="!isLoadingLyrics & !lyrics">
<p>
<translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate>
</p>
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
<i class="search icon"></i>
<translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate>
</a>
</template>
</section>
<section class="ui vertical stripe segment">
<h2>
<translate :translate-context="'Content/Track/Title'">User libraries</translate>
</h2>
<library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'">
<translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate>
</library-widget>
</section>
<router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view>
</template>
</main>
</template>
@ -169,7 +85,6 @@ import logger from "@/logging"
import PlayButton from "@/components/audio/PlayButton"
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
import LibraryWidget from "@/components/federation/LibraryWidget"
import Modal from '@/components/semantic/Modal'
import EmbedWizard from "@/components/audio/EmbedWizard"
@ -181,7 +96,6 @@ export default {
PlayButton,
TrackPlaylistIcon,
TrackFavoriteIcon,
LibraryWidget,
Modal,
EmbedWizard
},
@ -189,17 +103,13 @@ export default {
return {
time,
isLoadingTrack: true,
isLoadingLyrics: true,
track: null,
lyrics: null,
licenseData: null,
libraries: [],
showEmbedModal: false
showEmbedModal: false,
libraries: []
}
},
created() {
this.fetchData()
this.fetchLyrics()
},
methods: {
fetchData() {
@ -212,29 +122,6 @@ export default {
self.isLoadingTrack = false
})
},
fetchLicenseData(licenseId) {
var self = this
let url = `licenses/${licenseId}/`
axios.get(url).then(response => {
self.licenseData = response.data
})
},
fetchLyrics() {
var self = this
this.isLoadingLyrics = true
let url = FETCH_URL + this.id + "/lyrics/"
logger.default.debug('Fetching lyrics for track "' + this.id + '"')
axios.get(url).then(
response => {
self.lyrics = response.data
self.isLoadingLyrics = false
},
response => {
console.error("No lyrics available")
self.isLoadingLyrics = false
}
)
}
},
computed: {
publicLibraries () {
@ -242,16 +129,16 @@ export default {
return l.privacy_level === 'everyone'
})
},
labels() {
return {
title: this.$pgettext('Head/Track/Title', "Track")
}
},
upload() {
if (this.track.uploads) {
return this.track.uploads[0]
}
},
labels() {
return {
title: this.$pgettext('Head/Track/Title', "Track")
}
},
wikipediaUrl() {
return (
"https://en.wikipedia.org/w/index.php?search=" +
@ -276,11 +163,6 @@ export default {
}
return u
},
lyricsSearchUrl() {
let base = "http://lyrics.wikia.com/wiki/Special:Search?query="
let query = this.track.artist.name + ":" + this.track.title
return base + encodeURI(query)
},
cover() {
return null
},
@ -302,30 +184,11 @@ export default {
")"
)
},
license() {
if (!this.track || !this.track.license) {
return null
}
return this.licenseData
}
},
watch: {
id() {
this.fetchData()
},
track (v) {
if (v && v.license) {
this.fetchLicenseData(v.license)
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.table.center.aligned {
margin-left: auto;
margin-right: auto;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@ -94,7 +94,6 @@ export default {
date: new Date()
})
}, error => {
console.log('error', error)
logger.default.error(`Error while hiding ${self.type} ${self.target.id}`)
self.errors = error.backendErrors
self.isLoading = false

81
front/src/edits.js Normal file
View File

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

View File

@ -17,7 +17,10 @@ import LibraryArtist from '@/components/library/Artist'
import LibraryArtists from '@/components/library/Artists'
import LibraryAlbums from '@/components/library/Albums'
import LibraryAlbum from '@/components/library/Album'
import LibraryTrack from '@/components/library/Track'
import LibraryTrackDetail from '@/components/library/TrackDetail'
import LibraryTrackEdit from '@/components/library/TrackEdit'
import EditDetail from '@/components/library/EditDetail'
import LibraryTrackDetailBase from '@/components/library/TrackBase'
import LibraryRadios from '@/components/library/Radios'
import RadioBuilder from '@/components/library/radios/Builder'
import RadioDetail from '@/views/radios/Detail'
@ -26,7 +29,7 @@ import PlaylistList from '@/views/playlists/List'
import Favorites from '@/components/favorites/List'
import AdminSettings from '@/views/admin/Settings'
import AdminLibraryBase from '@/views/admin/library/Base'
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
import AdminLibraryEditsList from '@/views/admin/library/EditsList'
import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
@ -206,9 +209,14 @@ export default new Router({
component: AdminLibraryBase,
children: [
{
path: 'files',
name: 'manage.library.files',
component: AdminLibraryFilesList
path: 'edits',
name: 'manage.library.edits',
component: AdminLibraryEditsList,
props: (route) => {
return {
defaultQuery: route.query.q,
}
}
}
]
},
@ -324,7 +332,29 @@ export default new Router({
},
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
{
path: 'tracks/:id',
component: LibraryTrackDetailBase,
props: true,
children: [
{
path: '',
name: 'library.tracks.detail',
component: LibraryTrackDetail
},
{
path: 'edit',
name: 'library.tracks.edit',
component: LibraryTrackEdit
},
{
path: 'edit/:editId',
name: 'library.tracks.edit.detail',
component: EditDetail,
props: true,
}
]
},
]
},
{ path: '*', component: PageNotFound }

View File

@ -8,6 +8,7 @@ export default {
state: {
authenticated: false,
username: '',
fullUsername: '',
availablePermissions: {
settings: false,
library: false,
@ -27,6 +28,7 @@ export default {
state.authenticated = false
state.profile = null
state.username = ''
state.fullUsername = ''
state.token = ''
state.tokenData = {}
state.availablePermissions = {
@ -43,6 +45,7 @@ export default {
state.authenticated = value
if (value === false) {
state.username = null
state.fullUsername = null
state.token = null
state.tokenData = null
state.profile = null
@ -52,6 +55,9 @@ export default {
username: (state, value) => {
state.username = value
},
fullUsername: (state, value) => {
state.fullUsername = value
},
avatar: (state, value) => {
if (state.profile) {
state.profile.avatar = value
@ -124,6 +130,7 @@ export default {
resolve(response.data)
})
dispatch('ui/fetchUnreadNotifications', null, { root: true })
dispatch('ui/fetchPendingReviewEdits', null, { root: true })
dispatch('favorites/fetch', null, { root: true })
dispatch('moderation/fetchContentFilters', null, { root: true })
dispatch('playlists/fetchOwn', null, { root: true })
@ -138,6 +145,7 @@ export default {
commit("authenticated", true)
commit("profile", data)
commit("username", data.username)
commit("fullUsername", data.full_username)
Object.keys(data.permissions).forEach(function(key) {
// this makes it easier to check for permissions in templates
commit("permission", {

View File

@ -12,10 +12,13 @@ export default {
messages: [],
notifications: {
inbox: 0,
pendingReviewEdits: 0,
},
websocketEventsHandlers: {
'inbox.item_added': {},
'import.status_updated': {},
'mutation.created': {},
'mutation.updated': {},
}
},
mutations: {
@ -44,8 +47,12 @@ export default {
notifications (state, {type, count}) {
state.notifications[type] = count
},
incrementNotifications (state, {type, count}) {
state.notifications[type] = Math.max(0, state.notifications[type] + count)
incrementNotifications (state, {type, count, value}) {
if (value != undefined) {
state.notifications[type] = Math.max(0, value)
} else {
state.notifications[type] = Math.max(0, state.notifications[type] + count)
}
}
},
actions: {
@ -54,6 +61,11 @@ export default {
commit('notifications', {type: 'inbox', count: response.data.count})
})
},
fetchPendingReviewEdits ({commit, rootState}, payload) {
axios.get('mutations/', {params: {is_approved: 'null', page_size: 1}}).then((response) => {
commit('notifications', {type: 'pendingReviewEdits', count: response.data.count})
})
},
websocketEvent ({state}, event) {
let handlers = state.websocketEventsHandlers[event.type]
console.log('Dispatching websocket event', event, handlers)

View File

@ -276,3 +276,12 @@ canvas.color-thief {
.ui.dropdown .item[disabled] {
display: none;
}
span.diff.added {
background-color:rgba(0, 255, 0, 0.25);
}
span.diff.removed {
background-color: rgba(255, 0, 0, 0.25);
}

View File

@ -3,7 +3,7 @@
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.library.files'}"><translate :translate-context="'Menu/Admin/Link'">Files</translate></router-link>
:to="{name: 'manage.library.edits'}"><translate :translate-context="'Menu/Admin/Link'">Edits</translate></router-link>
</nav>
<router-view :key="$route.fullPath"></router-view>
</div>

View File

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

View File

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

View File

@ -2960,6 +2960,11 @@ diff@3.5.0, diff@^3.5.0:
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
diff@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff"
integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==
diffie-hellman@^5.0.0:
version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"